From 49c4fb02477637d18fa31a3c4c808b1ee1e5a943 Mon Sep 17 00:00:00 2001 From: Yadd Date: Fri, 5 Apr 2024 15:26:06 +0400 Subject: [PATCH 1/1] Import node-undici_5.28.4+dfsg1+~cs23.12.11.orig.tar.xz [dgit import orig node-undici_5.28.4+dfsg1+~cs23.12.11.orig.tar.xz] --- .dockerignore | 8 + .editorconfig | 9 + .github/ISSUE_TEMPLATE/bug-report.md | 34 + .github/ISSUE_TEMPLATE/feature-request.md | 28 + .github/PULL_REQUEST_TEMPLATE.md | 53 + .github/dependabot.yml | 23 + .github/workflows/bench.yml | 43 + .github/workflows/codeql.yml | 78 + .github/workflows/dependency-review.yml | 27 + .github/workflows/fuzz.yml | 39 + .github/workflows/lint.yml | 17 + .github/workflows/nodejs.yml | 45 + .github/workflows/publish-undici-types.yml | 26 + .github/workflows/scorecard.yml | 56 + .gitignore | 81 + .husky/pre-commit | 4 + .nojekyll | 0 .npmignore | 2 + .taprc | 7 + CNAME | 1 + CODE_OF_CONDUCT.md | 6 + CONTRIBUTING.md | 201 + GOVERNANCE.md | 136 + LICENSE | 21 + MAINTAINERS.md | 33 + README.md | 443 ++ SECURITY.md | 2 + benchmarks/benchmark-http2.js | 306 + benchmarks/benchmark-https.js | 319 ++ benchmarks/benchmark.js | 300 + benchmarks/fetch/bytes-match.mjs | 24 + benchmarks/server-http2.js | 49 + benchmarks/server-https.js | 41 + benchmarks/server.js | 33 + benchmarks/wait.js | 22 + build/Dockerfile | 18 + build/wasm.js | 101 + docs/api/Agent.md | 80 + docs/api/BalancedPool.md | 99 + docs/api/CacheStorage.md | 30 + docs/api/Client.md | 273 + docs/api/Connector.md | 115 + docs/api/ContentType.md | 57 + docs/api/Cookies.md | 101 + docs/api/DiagnosticsChannel.md | 204 + docs/api/DispatchInterceptor.md | 60 + docs/api/Dispatcher.md | 887 +++ docs/api/Errors.md | 47 + docs/api/Fetch.md | 27 + docs/api/MockAgent.md | 540 ++ docs/api/MockClient.md | 77 + docs/api/MockErrors.md | 12 + docs/api/MockPool.md | 547 ++ docs/api/Pool.md | 84 + docs/api/PoolStats.md | 35 + docs/api/ProxyAgent.md | 126 + docs/api/RetryHandler.md | 108 + docs/api/WebSocket.md | 43 + docs/api/api-lifecycle.md | 62 + docs/assets/lifecycle-diagram.png | Bin 0 -> 47090 bytes docs/best-practices/client-certificate.md | 64 + docs/best-practices/mocking-request.md | 136 + docs/best-practices/proxy.md | 127 + docs/best-practices/writing-tests.md | 20 + docsify/sidebar.md | 28 + examples/ca-fingerprint/index.js | 80 + examples/fetch.js | 13 + examples/proxy-agent.js | 25 + examples/proxy/index.js | 49 + examples/proxy/proxy.js | 256 + examples/request.js | 18 + index-fetch.js | 15 + index.d.ts | 3 + index.html | 35 + index.js | 167 + lib/agent.js | 148 + lib/api/abort-signal.js | 54 + lib/api/api-connect.js | 104 + lib/api/api-pipeline.js | 249 + lib/api/api-request.js | 180 + lib/api/api-stream.js | 220 + lib/api/api-upgrade.js | 105 + lib/api/index.js | 7 + lib/api/readable.js | 322 ++ lib/api/util.js | 46 + lib/balanced-pool.js | 190 + lib/cache/cache.js | 838 +++ lib/cache/cachestorage.js | 144 + lib/cache/symbols.js | 5 + lib/cache/util.js | 49 + lib/client.js | 2283 ++++++++ lib/compat/dispatcher-weakref.js | 48 + lib/cookies/constants.js | 12 + lib/cookies/index.js | 184 + lib/cookies/parse.js | 317 ++ lib/cookies/util.js | 291 + lib/core/connect.js | 189 + lib/core/constants.js | 118 + lib/core/errors.js | 230 + lib/core/request.js | 499 ++ lib/core/symbols.js | 63 + lib/core/util.js | 522 ++ lib/dispatcher-base.js | 192 + lib/dispatcher.js | 19 + lib/fetch/LICENSE | 21 + lib/fetch/body.js | 605 ++ lib/fetch/constants.js | 151 + lib/fetch/dataURL.js | 627 +++ lib/fetch/file.js | 344 ++ lib/fetch/formdata.js | 265 + lib/fetch/global.js | 40 + lib/fetch/headers.js | 589 ++ lib/fetch/index.js | 2148 +++++++ lib/fetch/request.js | 946 ++++ lib/fetch/response.js | 571 ++ lib/fetch/symbols.js | 10 + lib/fetch/util.js | 1144 ++++ lib/fetch/webidl.js | 646 +++ lib/fileapi/encoding.js | 290 + lib/fileapi/filereader.js | 344 ++ lib/fileapi/progressevent.js | 78 + lib/fileapi/symbols.js | 10 + lib/fileapi/util.js | 392 ++ lib/global.js | 32 + lib/handler/DecoratorHandler.js | 35 + lib/handler/RedirectHandler.js | 221 + lib/handler/RetryHandler.js | 336 ++ lib/interceptor/redirectInterceptor.js | 21 + lib/llhttp/constants.d.ts | 199 + lib/llhttp/constants.js | 278 + lib/llhttp/utils.d.ts | 4 + lib/llhttp/utils.js | 15 + lib/llhttp/wasm_build_env.txt | 32 + lib/mock/mock-agent.js | 171 + lib/mock/mock-client.js | 59 + lib/mock/mock-errors.js | 17 + lib/mock/mock-interceptor.js | 206 + lib/mock/mock-pool.js | 59 + lib/mock/mock-symbols.js | 23 + lib/mock/mock-utils.js | 351 ++ lib/mock/pending-interceptors-formatter.js | 40 + lib/mock/pluralizer.js | 29 + lib/node/fixed-queue.js | 117 + lib/pool-base.js | 194 + lib/pool-stats.js | 34 + lib/pool.js | 94 + lib/proxy-agent.js | 189 + lib/timers.js | 97 + lib/websocket/connection.js | 291 + lib/websocket/constants.js | 51 + lib/websocket/events.js | 303 + lib/websocket/frame.js | 73 + lib/websocket/receiver.js | 344 ++ lib/websocket/symbols.js | 12 + lib/websocket/util.js | 200 + lib/websocket/websocket.js | 641 +++ package.json | 167 + scripts/generate-pem.js | 3 + scripts/generate-undici-types-package-json.js | 28 + scripts/verifyVersion.js | 15 + test/abort-controller.js | 238 + test/abort-event-emitter.js | 259 + test/agent.js | 782 +++ test/async_hooks.js | 206 + test/autoselectfamily.js | 198 + test/balanced-pool.js | 565 ++ test/ca-fingerprint.js | 126 + test/client-abort.js | 213 + test/client-connect.js | 308 + test/client-dispatch.js | 815 +++ test/client-errors.js | 1285 +++++ test/client-head-reset-override.js | 62 + test/client-idempotent-body.js | 43 + test/client-keep-alive.js | 359 ++ test/client-node-max-header-size.js | 23 + test/client-pipeline.js | 1042 ++++ test/client-pipelining.js | 752 +++ test/client-post.js | 73 + test/client-reconnect.js | 54 + test/client-request.js | 997 ++++ test/client-stream.js | 847 +++ test/client-timeout.js | 197 + test/client-unref.js | 47 + test/client-upgrade.js | 452 ++ test/client-write-max-listeners.js | 51 + test/client.js | 2096 +++++++ test/close-and-destroy.js | 344 ++ test/connect-abort.js | 28 + test/connect-errconnect.js | 32 + test/connect-timeout.js | 68 + test/content-length.js | 445 ++ test/cookie/cookies.js | 616 ++ test/cookie/global-headers.js | 70 + test/diagnostics-channel/connect-error.js | 61 + test/diagnostics-channel/error.js | 52 + test/diagnostics-channel/get.js | 141 + test/diagnostics-channel/post-stream.js | 149 + test/diagnostics-channel/post.js | 147 + test/dispatcher.js | 22 + test/errors.js | 81 + test/esm-wrapper.js | 19 + test/fetch/407-statuscode-window-null.js | 20 + test/fetch/abort.js | 82 + test/fetch/abort2.js | 60 + test/fetch/about-uri.js | 21 + test/fetch/blob-uri.js | 100 + test/fetch/bundle.js | 41 + test/fetch/client-error-stack-trace.js | 21 + test/fetch/client-fetch.js | 688 +++ test/fetch/client-node-max-header-size.js | 29 + test/fetch/content-length.js | 29 + test/fetch/cookies.js | 69 + test/fetch/data-uri.js | 214 + test/fetch/encoding.js | 58 + test/fetch/fetch-leak.js | 44 + test/fetch/fetch-timeouts.js | 56 + test/fetch/file.js | 190 + test/fetch/formdata.js | 401 ++ test/fetch/general.js | 30 + test/fetch/headers.js | 743 +++ test/fetch/http2.js | 415 ++ test/fetch/integrity.js | 347 ++ test/fetch/issue-1447.js | 46 + test/fetch/issue-2009.js | 28 + test/fetch/issue-2021.js | 32 + test/fetch/issue-2171.js | 25 + test/fetch/issue-2242.js | 8 + test/fetch/issue-2318.js | 25 + test/fetch/issue-node-46525.js | 28 + test/fetch/iterators.js | 140 + .../jsdom-abortcontroller-1910-1464495619.js | 26 + test/fetch/redirect-cross-origin-header.js | 50 + test/fetch/redirect.js | 50 + test/fetch/relative-url.js | 110 + test/fetch/request.js | 514 ++ test/fetch/resource-timing.js | 72 + test/fetch/response-json.js | 113 + test/fetch/response.js | 257 + test/fetch/user-agent.js | 32 + test/fetch/util.js | 354 ++ test/fixed-queue.js | 38 + test/fixtures/ca.pem | 16 + test/fixtures/cert.pem | 18 + test/fixtures/client-ca-crt.pem | 17 + test/fixtures/client-crt-2048.pem | 22 + test/fixtures/client-crt.pem | 17 + test/fixtures/client-key-2048.pem | 27 + test/fixtures/client-key.pem | 27 + test/fixtures/key.pem | 15 + test/fuzzing/client/client-fuzz-body.js | 28 + test/fuzzing/client/client-fuzz-headers.js | 27 + test/fuzzing/client/client-fuzz-options.js | 38 + test/fuzzing/client/index.js | 7 + test/fuzzing/fuzz.js | 66 + test/fuzzing/server/index.js | 6 + .../fuzzing/server/server-fuzz-append-data.js | 7 + test/fuzzing/server/server-fuzz-split-data.js | 17 + test/gc.js | 98 + test/get-head-body.js | 184 + test/headers-as-array.js | 131 + test/headers-crlf.js | 36 + test/http-100.js | 141 + test/http-req-destroy.js | 69 + test/http2-alpn.js | 277 + test/http2.js | 1191 ++++ test/https.js | 74 + test/imports/undici-import.ts | 5 + test/inflight-and-close.js | 31 + test/invalid-headers.js | 108 + test/issue-1670.js | 12 + test/issue-1903.js | 78 + test/issue-2065.js | 71 + test/issue-2078.js | 30 + test/issue-2349.js | 53 + test/issue-803.js | 47 + test/issue-810.js | 135 + test/jest/instanceof-error.test.js | 44 + test/jest/interceptor.test.js | 197 + test/jest/issue-1757.test.js | 61 + test/jest/mock-agent.test.js | 46 + test/jest/mock-scope.test.js | 32 + test/jest/test.js | 36 + test/max-headers.js | 41 + test/max-response-size.js | 105 + test/mock-agent.js | 2637 +++++++++ test/mock-client.js | 446 ++ test/mock-errors.js | 32 + test/mock-interceptor-unused-assertions.js | 268 + test/mock-interceptor.js | 258 + test/mock-pool.js | 369 ++ test/mock-scope.js | 73 + test/mock-utils.js | 160 + test/no-strict-content-length.js | 349 ++ test/node-fetch/LICENSE | 22 + test/node-fetch/headers.js | 282 + test/node-fetch/main.js | 1661 ++++++ test/node-fetch/mock.js | 112 + test/node-fetch/request.js | 281 + test/node-fetch/response.js | 251 + test/node-fetch/utils/chai-timeout.js | 15 + test/node-fetch/utils/dummy.txt | 1 + test/node-fetch/utils/read-stream.js | 9 + test/node-fetch/utils/server.js | 467 ++ test/parser-issues.js | 114 + test/pipeline-pipelining.js | 108 + test/pool.js | 1101 ++++ test/promises.js | 280 + test/proxy-agent.js | 720 +++ test/proxy.js | 132 + test/readable.test.js | 23 + test/redirect-cross-origin-header.js | 51 + test/redirect-pipeline.js | 50 + test/redirect-relative.js | 22 + test/redirect-request.js | 420 ++ test/redirect-stream.js | 423 ++ test/redirect-upgrade.js | 34 + test/request-crlf.js | 32 + test/request-timeout.js | 820 +++ test/request-timeout2.js | 48 + test/request.js | 248 + test/retry-handler.js | 622 +++ test/socket-back-pressure.js | 54 + test/socket-timeout.js | 100 + test/stream-compat.js | 75 + test/tls-client-cert.js | 70 + test/tls-session-reuse.js | 185 + test/tls.js | 188 + test/trailers.js | 57 + test/types/agent.test-d.ts | 110 + test/types/api.test-d.ts | 28 + test/types/balanced-pool.test-d.ts | 113 + test/types/cache-storage.test-d.ts | 39 + test/types/client.test-d.ts | 185 + test/types/connector.test-d.ts | 38 + test/types/diagnostics-channel.test-d.ts | 72 + test/types/dispatcher.events.test-d.ts | 45 + test/types/dispatcher.test-d.ts | 123 + test/types/errors.test-d.ts | 115 + test/types/fetch.test-d.ts | 173 + test/types/formdata.test-d.ts | 27 + test/types/global-dispatcher.test-d.ts | 12 + test/types/header.test-d.ts | 16 + test/types/index.test-d.ts | 23 + test/types/interceptor.test-d.ts | 5 + test/types/mock-agent.test-d.ts | 75 + test/types/mock-client.test-d.ts | 43 + test/types/mock-errors.test-d.ts | 19 + test/types/mock-interceptor.test-d.ts | 80 + test/types/mock-pool.test-d.ts | 42 + test/types/pool.test-d.ts | 112 + test/types/proxy-agent.test-d.ts | 43 + test/types/readable.test-d.ts | 34 + test/unix.js | 141 + test/util.js | 123 + test/utils/async-iterators.js | 25 + test/utils/esm-wrapper.mjs | 102 + test/utils/formdata.js | 49 + test/utils/redirecting-servers.js | 265 + test/utils/stream.js | 48 + test/validations.js | 63 + test/webidl/converters.js | 202 + test/webidl/helpers.js | 75 + test/webidl/util.js | 106 + test/websocket/close.js | 130 + test/websocket/constructor.js | 48 + test/websocket/custom-headers.js | 30 + test/websocket/diagnostics-channel.js | 71 + test/websocket/events.js | 204 + test/websocket/fragments.js | 40 + test/websocket/frame.js | 24 + test/websocket/opening-handshake.js | 215 + test/websocket/ping-pong.js | 46 + test/websocket/receive.js | 60 + test/websocket/send.js | 216 + test/websocket/websocketinit.js | 45 + test/wpt/runner/runner.mjs | 356 ++ test/wpt/runner/util.mjs | 172 + test/wpt/runner/worker.mjs | 164 + .../server/routes/network-partition-key.mjs | 111 + test/wpt/server/routes/redirect.mjs | 104 + test/wpt/server/server.mjs | 397 ++ test/wpt/server/websocket.mjs | 46 + test/wpt/start-FileAPI.mjs | 26 + test/wpt/start-cacheStorage.mjs | 26 + test/wpt/start-fetch.mjs | 31 + test/wpt/start-mimesniff.mjs | 31 + test/wpt/start-websockets.mjs | 47 + test/wpt/start-xhr.mjs | 12 + test/wpt/status/FileAPI.status.json | 75 + test/wpt/status/fetch.status.json | 457 ++ test/wpt/status/mimesniff.status.json | 7 + .../service-workers/cache-storage.status.json | 24 + test/wpt/status/websockets.status.json | 115 + test/wpt/status/xhr/formdata.status.json | 1 + test/wpt/tests/.azure-pipelines.yml | 595 ++ test/wpt/tests/.gitattributes | 1 + test/wpt/tests/.gitignore | 52 + test/wpt/tests/.mailmap | 9 + test/wpt/tests/.taskcluster.yml | 82 + test/wpt/tests/CODEOWNERS | 6 + test/wpt/tests/CODE_OF_CONDUCT.md | 138 + test/wpt/tests/CONTRIBUTING.md | 11 + .../Blob-methods-from-detached-frame.html | 59 + .../cross-partition.tentative.https.html | 276 + .../FileAPI/BlobURL/support/file_test2.txt | 0 .../tests/FileAPI/BlobURL/test2-manual.html | 62 + .../progress_event_bubbles_cancelable.html | 33 + .../FileAPI/FileReader/support/file_test1.txt | 0 .../FileReader/test_errors-manual.html | 72 + .../test_notreadableerrors-manual.html | 42 + .../test_securityerrors-manual.html | 40 + .../wpt/tests/FileAPI/FileReader/workers.html | 27 + .../tests/FileAPI/FileReaderSync.worker.js | 56 + test/wpt/tests/FileAPI/META.yml | 6 + .../FileAPI/blob/Blob-array-buffer.any.js | 45 + .../blob/Blob-constructor-dom.window.js | 53 + .../blob/Blob-constructor-endings.html | 104 + .../FileAPI/blob/Blob-constructor.any.js | 468 ++ .../FileAPI/blob/Blob-in-worker.worker.js | 9 + .../FileAPI/blob/Blob-slice-overflow.any.js | 32 + test/wpt/tests/FileAPI/blob/Blob-slice.any.js | 231 + .../FileAPI/blob/Blob-stream-byob-crash.html | 11 + .../blob/Blob-stream-sync-xhr-crash.html | 13 + .../wpt/tests/FileAPI/blob/Blob-stream.any.js | 83 + test/wpt/tests/FileAPI/blob/Blob-text.any.js | 64 + .../file/File-constructor-endings.html | 104 + .../FileAPI/file/File-constructor.any.js | 155 + .../Worker-read-file-constructor.worker.js | 15 + .../file/resources/echo-content-escaped.py | 26 + .../FileAPI/file/send-file-form-controls.html | 113 + .../file/send-file-form-iso-2022-jp.html | 65 + .../file/send-file-form-punctuation.html | 226 + .../FileAPI/file/send-file-form-utf-8.html | 62 + .../file/send-file-form-windows-1252.html | 62 + .../file/send-file-form-x-user-defined.html | 63 + .../tests/FileAPI/file/send-file-form.html | 25 + .../file/send-file-formdata-controls.any.js | 69 + .../send-file-formdata-punctuation.any.js | 144 + .../file/send-file-formdata-utf-8.any.js | 33 + .../FileAPI/file/send-file-formdata.any.js | 8 + test/wpt/tests/FileAPI/fileReader.any.js | 59 + .../FileAPI/filelist-section/filelist.html | 57 + ...lelist_multiple_selected_files-manual.html | 64 + .../filelist_selected_file-manual.html | 64 + .../filelist-section/support/upload.txt | 1 + .../filelist-section/support/upload.zip | Bin 0 -> 220 bytes test/wpt/tests/FileAPI/historical.https.html | 65 + test/wpt/tests/FileAPI/idlharness-manual.html | 45 + test/wpt/tests/FileAPI/idlharness.any.js | 19 + test/wpt/tests/FileAPI/idlharness.html | 37 + test/wpt/tests/FileAPI/idlharness.worker.js | 17 + test/wpt/tests/FileAPI/progress-manual.html | 49 + .../Determining-Encoding.any.js | 81 + ...FileReader-event-handler-attributes.any.js | 17 + .../FileReader-multiple-reads.any.js | 81 + .../filereader_abort.any.js | 38 + .../filereader_error.any.js | 19 + .../filereader_events.any.js | 19 + .../filereader_file-manual.html | 69 + .../filereader_file_img-manual.html | 47 + .../filereader_readAsArrayBuffer.any.js | 23 + .../filereader_readAsBinaryString.any.js | 23 + .../filereader_readAsDataURL.any.js | 54 + .../filereader_readAsText.any.js | 36 + .../filereader_readystate.any.js | 19 + .../filereader_result.any.js | 82 + .../support/blue-100x100.png | Bin 0 -> 227 bytes test/wpt/tests/FileAPI/support/Blob.js | 70 + .../support/document-domain-setter.sub.html | 7 + .../tests/FileAPI/support/empty-document.html | 3 + .../support/historical-serviceworker.js | 5 + .../tests/FileAPI/support/incumbent.sub.html | 22 + .../FileAPI/support/send-file-form-helper.js | 282 + .../support/send-file-formdata-helper.js | 99 + test/wpt/tests/FileAPI/support/upload.txt | 1 + .../wpt/tests/FileAPI/support/url-origin.html | 6 + test/wpt/tests/FileAPI/unicode.html | 46 + .../FileAPI/url/cross-global-revoke.sub.html | 62 + ...multi-global-origin-serialization.sub.html | 26 + .../FileAPI/url/resources/create-helper.html | 7 + .../FileAPI/url/resources/create-helper.js | 4 + .../FileAPI/url/resources/fetch-tests.js | 71 + .../FileAPI/url/resources/revoke-helper.html | 7 + .../FileAPI/url/resources/revoke-helper.js | 9 + .../tests/FileAPI/url/sandboxed-iframe.html | 32 + .../tests/FileAPI/url/unicode-origin.sub.html | 23 + .../tests/FileAPI/url/url-charset.window.js | 34 + test/wpt/tests/FileAPI/url/url-format.any.js | 70 + .../FileAPI/url/url-in-tags-revoke.window.js | 115 + .../tests/FileAPI/url/url-in-tags.window.js | 48 + test/wpt/tests/FileAPI/url/url-lifetime.html | 56 + .../tests/FileAPI/url/url-reload.window.js | 36 + .../tests/FileAPI/url/url-with-fetch.any.js | 72 + .../wpt/tests/FileAPI/url/url-with-xhr.any.js | 68 + .../url/url_createobjecturl_file-manual.html | 45 + .../url_createobjecturl_file_img-manual.html | 28 + .../url/url_xmlhttprequest_img-ref.html | 12 + .../FileAPI/url/url_xmlhttprequest_img.html | 27 + test/wpt/tests/LICENSE.md | 11 + test/wpt/tests/README.md | 124 + test/wpt/tests/common/CustomCorsResponse.py | 30 + test/wpt/tests/common/META.yml | 3 + test/wpt/tests/common/PrefixedLocalStorage.js | 116 + .../common/PrefixedLocalStorage.js.headers | 1 + test/wpt/tests/common/PrefixedPostMessage.js | 100 + .../common/PrefixedPostMessage.js.headers | 1 + test/wpt/tests/common/README.md | 10 + test/wpt/tests/common/__init__.py | 0 test/wpt/tests/common/arrays.js | 31 + test/wpt/tests/common/blank-with-cors.html | 0 .../tests/common/blank-with-cors.html.headers | 1 + test/wpt/tests/common/blank.html | 0 test/wpt/tests/common/custom-cors-response.js | 32 + test/wpt/tests/common/dispatcher/README.md | 228 + .../wpt/tests/common/dispatcher/dispatcher.js | 256 + .../wpt/tests/common/dispatcher/dispatcher.py | 53 + .../dispatcher/executor-service-worker.js | 24 + .../common/dispatcher/executor-worker.js | 12 + .../wpt/tests/common/dispatcher/executor.html | 15 + .../common/dispatcher/remote-executor.html | 12 + test/wpt/tests/common/domain-setter.sub.html | 8 + test/wpt/tests/common/dummy.xhtml | 2 + test/wpt/tests/common/dummy.xml | 1 + test/wpt/tests/common/echo.py | 6 + test/wpt/tests/common/gc.js | 52 + test/wpt/tests/common/get-host-info.sub.js | 63 + .../tests/common/get-host-info.sub.js.headers | 1 + test/wpt/tests/common/media.js | 61 + test/wpt/tests/common/media.js.headers | 1 + test/wpt/tests/common/object-association.js | 74 + .../common/object-association.js.headers | 1 + .../common/performance-timeline-utils.js | 56 + .../performance-timeline-utils.js.headers | 1 + test/wpt/tests/common/proxy-all.sub.pac | 3 + test/wpt/tests/common/redirect-opt-in.py | 20 + test/wpt/tests/common/redirect.py | 19 + test/wpt/tests/common/refresh.py | 11 + test/wpt/tests/common/reftest-wait.js | 39 + test/wpt/tests/common/reftest-wait.js.headers | 1 + test/wpt/tests/common/rendering-utils.js | 19 + test/wpt/tests/common/sab.js | 21 + .../tests/common/security-features/README.md | 460 ++ .../common/security-features/__init__.py | 0 .../security-features/resources/common.sub.js | 1311 +++++ .../resources/common.sub.js.headers | 1 + .../security-features/scope/__init__.py | 0 .../security-features/scope/document.py | 36 + .../scope/template/document.html.template | 30 + .../scope/template/worker.js.template | 29 + .../common/security-features/scope/util.py | 43 + .../common/security-features/scope/worker.py | 44 + .../security-features/subresource/__init__.py | 0 .../security-features/subresource/audio.py | 18 + .../security-features/subresource/document.py | 12 + .../security-features/subresource/empty.py | 14 + .../security-features/subresource/font.py | 76 + .../security-features/subresource/image.py | 116 + .../security-features/subresource/referrer.py | 4 + .../security-features/subresource/script.py | 14 + .../subresource/shared-worker.py | 13 + .../subresource/static-import.py | 61 + .../subresource/stylesheet.py | 61 + .../subresource/subresource.py | 199 + .../security-features/subresource/svg.py | 37 + .../template/document.html.template | 16 + .../subresource/template/font.css.template | 9 + .../subresource/template/image.css.template | 3 + .../subresource/template/script.js.template | 3 + .../template/shared-worker.js.template | 5 + .../template/static-import.js.template | 1 + .../subresource/template/svg.css.template | 3 + .../template/svg.embedded.template | 5 + .../subresource/template/worker.js.template | 3 + .../security-features/subresource/video.py | 17 + .../security-features/subresource/worker.py | 13 + .../security-features/subresource/xhr.py | 16 + .../tools/format_spec_src_json.py | 24 + .../security-features/tools/generate.py | 462 ++ .../security-features/tools/spec.src.json | 533 ++ .../security-features/tools/spec_validator.py | 251 + .../tools/template/disclaimer.template | 1 + .../tools/template/spec_json.js.template | 1 + .../tools/template/test.debug.html.template | 26 + .../tools/template/test.release.html.template | 22 + .../common/security-features/tools/util.py | 228 + .../tests/common/security-features/types.md | 62 + test/wpt/tests/common/slow-redirect.py | 29 + test/wpt/tests/common/slow.py | 6 + test/wpt/tests/common/square.png | Bin 0 -> 18299 bytes test/wpt/tests/common/stringifiers.js | 57 + test/wpt/tests/common/stringifiers.js.headers | 1 + test/wpt/tests/common/subset-tests-by-key.js | 83 + test/wpt/tests/common/subset-tests.js | 60 + .../test-setting-immutable-prototype.js | 67 + ...est-setting-immutable-prototype.js.headers | 1 + test/wpt/tests/common/text-plain.txt | 4 + .../common/third_party/reftest-analyzer.xhtml | 934 ++++ test/wpt/tests/common/utils.js | 98 + test/wpt/tests/common/utils.js.headers | 1 + test/wpt/tests/common/window-name-setter.html | 12 + test/wpt/tests/common/worklet-reftest.js | 50 + .../tests/common/worklet-reftest.js.headers | 1 + test/wpt/tests/fetch/META.yml | 7 + test/wpt/tests/fetch/README.md | 6 + .../tests/fetch/api/abort/cache.https.any.js | 47 + .../fetch/api/abort/destroyed-context.html | 27 + test/wpt/tests/fetch/api/abort/general.any.js | 572 ++ test/wpt/tests/fetch/api/abort/keepalive.html | 85 + test/wpt/tests/fetch/api/abort/request.any.js | 85 + .../serviceworker-intercepted.https.html | 212 + .../fetch/api/basic/accept-header.any.js | 34 + .../fetch/api/basic/block-mime-as-script.html | 43 + .../fetch/api/basic/conditional-get.any.js | 38 + .../api/basic/error-after-response.any.js | 24 + .../api/basic/header-value-combining.any.js | 15 + .../api/basic/header-value-null-byte.any.js | 5 + .../tests/fetch/api/basic/historical.any.js | 17 + .../fetch/api/basic/http-response-code.any.js | 14 + .../fetch/api/basic/integrity.sub.any.js | 87 + .../tests/fetch/api/basic/keepalive.any.js | 43 + .../fetch/api/basic/mediasource.window.js | 5 + .../fetch/api/basic/mode-no-cors.sub.any.js | 29 + .../fetch/api/basic/mode-same-origin.any.js | 28 + .../wpt/tests/fetch/api/basic/referrer.any.js | 29 + .../basic/request-forbidden-headers.any.js | 100 + .../tests/fetch/api/basic/request-head.any.js | 6 + .../api/basic/request-headers-case.any.js | 13 + .../api/basic/request-headers-nonascii.any.js | 29 + .../fetch/api/basic/request-headers.any.js | 82 + .../request-referrer-redirected-worker.html | 17 + .../fetch/api/basic/request-referrer.any.js | 24 + .../fetch/api/basic/request-upload.any.js | 135 + .../fetch/api/basic/request-upload.h2.any.js | 186 + .../fetch/api/basic/response-null-body.any.js | 38 + .../fetch/api/basic/response-url.sub.any.js | 16 + .../tests/fetch/api/basic/scheme-about.any.js | 26 + .../fetch/api/basic/scheme-blob.sub.any.js | 125 + .../tests/fetch/api/basic/scheme-data.any.js | 43 + .../fetch/api/basic/scheme-others.sub.any.js | 31 + .../tests/fetch/api/basic/status.h2.any.js | 17 + .../fetch/api/basic/stream-response.any.js | 40 + .../api/basic/stream-safe-creation.any.js | 54 + .../tests/fetch/api/basic/text-utf8.any.js | 74 + test/wpt/tests/fetch/api/body/cloned-any.js | 50 + test/wpt/tests/fetch/api/body/formdata.any.js | 14 + .../wpt/tests/fetch/api/body/mime-type.any.js | 127 + .../tests/fetch/api/cors/cors-basic.any.js | 43 + .../api/cors/cors-cookies-redirect.any.js | 49 + .../tests/fetch/api/cors/cors-cookies.any.js | 56 + .../api/cors/cors-expose-star.sub.any.js | 41 + .../fetch/api/cors/cors-filtering.sub.any.js | 69 + .../fetch/api/cors/cors-keepalive.any.js | 118 + .../api/cors/cors-multiple-origins.sub.any.js | 22 + .../fetch/api/cors/cors-no-preflight.any.js | 41 + .../tests/fetch/api/cors/cors-origin.any.js | 51 + .../api/cors/cors-preflight-cache.any.js | 46 + .../cors-preflight-not-cors-safelisted.any.js | 19 + .../api/cors/cors-preflight-redirect.any.js | 37 + .../api/cors/cors-preflight-referrer.any.js | 51 + .../cors-preflight-response-validation.any.js | 33 + .../fetch/api/cors/cors-preflight-star.any.js | 86 + .../api/cors/cors-preflight-status.any.js | 37 + .../fetch/api/cors/cors-preflight.any.js | 62 + .../api/cors/cors-redirect-credentials.any.js | 52 + .../api/cors/cors-redirect-preflight.any.js | 46 + .../tests/fetch/api/cors/cors-redirect.any.js | 42 + .../tests/fetch/api/cors/data-url-iframe.html | 58 + .../api/cors/data-url-shared-worker.html | 53 + .../tests/fetch/api/cors/data-url-worker.html | 50 + .../fetch/api/cors/resources/corspreflight.js | 58 + .../cors/resources/not-cors-safelisted.json | 13 + .../fetch/api/cors/sandboxed-iframe.html | 14 + .../api/crashtests/body-window-destroy.html | 11 + .../tests/fetch/api/crashtests/request.html | 8 + .../credentials/authentication-basic.any.js | 17 + .../authentication-redirection.any.js | 29 + .../fetch/api/credentials/cookies.any.js | 49 + .../fetch/api/headers/header-setcookie.any.js | 266 + .../headers/header-values-normalize.any.js | 72 + .../fetch/api/headers/header-values.any.js | 63 + .../fetch/api/headers/headers-basic.any.js | 275 + .../fetch/api/headers/headers-casing.any.js | 54 + .../fetch/api/headers/headers-combine.any.js | 66 + .../fetch/api/headers/headers-errors.any.js | 96 + .../fetch/api/headers/headers-no-cors.any.js | 59 + .../api/headers/headers-normalize.any.js | 56 + .../fetch/api/headers/headers-record.any.js | 357 ++ .../api/headers/headers-structure.any.js | 20 + test/wpt/tests/fetch/api/idlharness.any.js | 21 + .../api/policies/csp-blocked-worker.html | 16 + .../tests/fetch/api/policies/csp-blocked.html | 15 + .../api/policies/csp-blocked.html.headers | 1 + .../tests/fetch/api/policies/csp-blocked.js | 13 + .../fetch/api/policies/csp-blocked.js.headers | 1 + .../tests/fetch/api/policies/nested-policy.js | 1 + .../api/policies/nested-policy.js.headers | 1 + ...rrer-no-referrer-service-worker.https.html | 18 + .../policies/referrer-no-referrer-worker.html | 17 + .../api/policies/referrer-no-referrer.html | 15 + .../referrer-no-referrer.html.headers | 1 + .../api/policies/referrer-no-referrer.js | 19 + .../policies/referrer-no-referrer.js.headers | 1 + .../referrer-origin-service-worker.https.html | 18 + ...hen-cross-origin-service-worker.https.html | 17 + ...errer-origin-when-cross-origin-worker.html | 16 + .../referrer-origin-when-cross-origin.html | 16 + ...rrer-origin-when-cross-origin.html.headers | 1 + .../referrer-origin-when-cross-origin.js | 21 + ...ferrer-origin-when-cross-origin.js.headers | 1 + .../api/policies/referrer-origin-worker.html | 17 + .../fetch/api/policies/referrer-origin.html | 16 + .../api/policies/referrer-origin.html.headers | 1 + .../fetch/api/policies/referrer-origin.js | 30 + .../api/policies/referrer-origin.js.headers | 1 + ...errer-unsafe-url-service-worker.https.html | 18 + .../policies/referrer-unsafe-url-worker.html | 17 + .../api/policies/referrer-unsafe-url.html | 16 + .../policies/referrer-unsafe-url.html.headers | 1 + .../fetch/api/policies/referrer-unsafe-url.js | 21 + .../policies/referrer-unsafe-url.js.headers | 1 + .../redirect-back-to-original-origin.any.js | 38 + .../fetch/api/redirect/redirect-count.any.js | 51 + .../redirect/redirect-empty-location.any.js | 21 + .../api/redirect/redirect-keepalive.any.js | 94 + .../redirect-location-escape.tentative.any.js | 46 + .../api/redirect/redirect-location.any.js | 73 + .../fetch/api/redirect/redirect-method.any.js | 112 + .../fetch/api/redirect/redirect-mode.any.js | 59 + .../fetch/api/redirect/redirect-origin.any.js | 68 + .../redirect-referrer-override.any.js | 104 + .../api/redirect/redirect-referrer.any.js | 66 + .../api/redirect/redirect-schemes.any.js | 19 + .../api/redirect/redirect-to-dataurl.any.js | 28 + .../api/redirect/redirect-upload.h2.any.js | 33 + .../fetch-destination-frame.https.html | 51 + .../fetch-destination-iframe.https.html | 51 + ...fetch-destination-no-load-event.https.html | 124 + .../fetch-destination-prefetch.https.html | 46 + .../fetch-destination-worker.https.html | 60 + .../destination/fetch-destination.https.html | 435 ++ .../api/request/destination/resources/dummy | 0 .../request/destination/resources/dummy.es | 0 .../destination/resources/dummy.es.headers | 1 + .../request/destination/resources/dummy.html | 0 .../request/destination/resources/dummy.png | Bin 0 -> 18299 bytes .../request/destination/resources/dummy.ttf | Bin 0 -> 2528 bytes .../destination/resources/dummy_audio.mp3 | Bin 0 -> 20498 bytes .../destination/resources/dummy_audio.oga | Bin 0 -> 18541 bytes .../destination/resources/dummy_video.mp4 | Bin 0 -> 67369 bytes .../destination/resources/dummy_video.ogv | Bin 0 -> 94372 bytes .../destination/resources/dummy_video.webm | Bin 0 -> 96902 bytes .../destination/resources/empty.https.html | 0 .../fetch-destination-worker-frame.js | 20 + .../fetch-destination-worker-iframe.js | 20 + .../fetch-destination-worker-no-load-event.js | 20 + .../resources/fetch-destination-worker.js | 12 + .../request/destination/resources/importer.js | 1 + .../fetch/api/request/forbidden-method.any.js | 13 + .../construct-in-detached-frame.window.js | 11 + .../multi-globals/current/current.html | 3 + .../multi-globals/incumbent/incumbent.html | 14 + .../request/multi-globals/url-parsing.html | 27 + .../fetch/api/request/request-bad-port.any.js | 92 + .../request-cache-default-conditional.any.js | 170 + .../api/request/request-cache-default.any.js | 39 + .../request/request-cache-force-cache.any.js | 67 + .../api/request/request-cache-no-cache.any.js | 25 + .../api/request/request-cache-no-store.any.js | 37 + .../request-cache-only-if-cached.any.js | 66 + .../api/request/request-cache-reload.any.js | 51 + .../tests/fetch/api/request/request-cache.js | 223 + .../fetch/api/request/request-clone.sub.html | 63 + .../api/request/request-consume-empty.any.js | 101 + .../fetch/api/request/request-consume.any.js | 145 + .../api/request/request-disturbed.any.js | 109 + .../fetch/api/request/request-error.any.js | 56 + .../tests/fetch/api/request/request-error.js | 57 + .../fetch/api/request/request-headers.any.js | 178 + .../api/request/request-init-001.sub.html | 112 + .../fetch/api/request/request-init-002.any.js | 60 + .../api/request/request-init-003.sub.html | 84 + .../request/request-init-contenttype.any.js | 141 + .../api/request/request-init-priority.any.js | 26 + .../api/request/request-init-stream.any.js | 147 + .../api/request/request-keepalive-quota.html | 97 + .../api/request/request-keepalive.any.js | 17 + .../request-reset-attributes.https.html | 96 + .../api/request/request-structure.any.js | 143 + .../fetch/api/request/resources/cache.py | 67 + .../fetch/api/request/resources/hello.txt | 1 + .../request-reset-attributes-worker.js | 19 + .../tests/fetch/api/request/url-encoding.html | 25 + .../fetch/api/resources/authentication.py | 14 + .../fetch/api/resources/bad-chunk-encoding.py | 13 + test/wpt/tests/fetch/api/resources/basic.html | 5 + test/wpt/tests/fetch/api/resources/cache.py | 18 + .../tests/fetch/api/resources/clean-stash.py | 6 + .../tests/fetch/api/resources/cors-top.txt | 1 + .../fetch/api/resources/cors-top.txt.headers | 1 + test/wpt/tests/fetch/api/resources/data.json | 1 + .../resources/dump-authorization-header.py | 14 + .../fetch/api/resources/echo-content.h2.py | 7 + .../tests/fetch/api/resources/echo-content.py | 12 + test/wpt/tests/fetch/api/resources/empty.txt | 0 .../api/resources/infinite-slow-response.py | 35 + .../fetch/api/resources/inspect-headers.py | 24 + .../fetch/api/resources/keepalive-helper.js | 99 + .../fetch/api/resources/keepalive-iframe.html | 21 + .../resources/keepalive-redirect-iframe.html | 23 + .../resources/keepalive-redirect-window.html | 42 + test/wpt/tests/fetch/api/resources/method.py | 18 + .../tests/fetch/api/resources/preflight.py | 78 + .../api/resources/redirect-empty-location.py | 3 + .../tests/fetch/api/resources/redirect.h2.py | 14 + .../wpt/tests/fetch/api/resources/redirect.py | 73 + .../fetch/api/resources/sandboxed-iframe.html | 34 + .../fetch/api/resources/script-with-header.py | 7 + .../tests/fetch/api/resources/stash-put.py | 19 + .../tests/fetch/api/resources/stash-take.py | 9 + test/wpt/tests/fetch/api/resources/status.py | 11 + .../fetch/api/resources/sw-intercept-abort.js | 19 + .../tests/fetch/api/resources/sw-intercept.js | 10 + test/wpt/tests/fetch/api/resources/top.txt | 1 + test/wpt/tests/fetch/api/resources/trickle.py | 15 + test/wpt/tests/fetch/api/resources/utils.js | 105 + test/wpt/tests/fetch/api/response/json.any.js | 14 + .../api/response/many-empty-chunks-crash.html | 14 + .../multi-globals/current/current.html | 3 + .../multi-globals/incumbent/incumbent.html | 16 + .../multi-globals/relevant/relevant.html | 2 + .../response/multi-globals/url-parsing.html | 27 + .../response-body-read-task-handling.html | 86 + .../response/response-cancel-stream.any.js | 64 + .../response/response-clone-iframe.window.js | 32 + .../fetch/api/response/response-clone.any.js | 140 + .../response/response-consume-empty.any.js | 99 + .../response/response-consume-stream.any.js | 61 + .../fetch/api/response/response-consume.html | 317 ++ .../response-error-from-stream.any.js | 59 + .../fetch/api/response/response-error.any.js | 27 + .../api/response/response-from-stream.any.js | 23 + .../api/response/response-init-001.any.js | 64 + .../api/response/response-init-002.any.js | 61 + .../response/response-init-contenttype.any.js | 125 + .../api/response/response-static-error.any.js | 34 + .../api/response/response-static-json.any.js | 96 + .../response/response-static-redirect.any.js | 40 + .../response/response-stream-bad-chunk.any.js | 24 + .../response-stream-disturbed-1.any.js | 44 + .../response-stream-disturbed-2.any.js | 35 + .../response-stream-disturbed-3.any.js | 36 + .../response-stream-disturbed-4.any.js | 35 + .../response-stream-disturbed-5.any.js | 19 + .../response-stream-disturbed-6.any.js | 76 + .../response-stream-disturbed-by-pipe.any.js | 17 + .../response-stream-disturbed-util.js | 17 + .../response-stream-with-broken-then.any.js | 117 + .../network-partition-key.html | 264 + ...network-partition-about-blank-checker.html | 35 + .../resources/network-partition-checker.html | 30 + .../network-partition-iframe-checker.html | 22 + .../resources/network-partition-key.js | 47 + .../resources/network-partition-key.py | 130 + .../network-partition-worker-checker.html | 24 + .../resources/network-partition-worker.js | 15 + .../content-encoding/bad-gzip-body.any.js | 22 + .../fetch/content-encoding/gzip-body.any.js | 16 + .../resources/bad-gzip-body.py | 3 + .../resources/foo.octetstream.gz | Bin 0 -> 64 bytes .../resources/foo.octetstream.gz.headers | 2 + .../content-encoding/resources/foo.text.gz | Bin 0 -> 57 bytes .../resources/foo.text.gz.headers | 2 + .../api-and-duplicate-headers.any.js | 23 + .../fetch/content-length/content-length.html | 14 + .../content-length.html.headers | 1 + .../fetch/content-length/parsing.window.js | 18 + .../resources/content-length.py | 10 + .../resources/content-lengths.json | 142 + .../resources/identical-duplicates.asis | 9 + .../fetch/content-length/too-long.window.js | 4 + test/wpt/tests/fetch/content-type/README.md | 20 + .../content-type/multipart-malformed.any.js | 22 + .../fetch/content-type/multipart.window.js | 33 + .../content-type/resources/content-type.py | 18 + .../content-type/resources/content-types.json | 122 + .../resources/script-content-types.json | 92 + .../fetch/content-type/response.window.js | 72 + .../tests/fetch/content-type/script.window.js | 48 + test/wpt/tests/fetch/corb/README.md | 67 + .../img-html-correctly-labeled.sub-ref.html | 4 + .../corb/img-html-correctly-labeled.sub.html | 11 + ...img-mime-types-coverage.tentative.sub.html | 85 + ...led-as-html-nosniff.tentative.sub-ref.html | 4 + ...labeled-as-html-nosniff.tentative.sub.html | 11 + .../img-png-mislabeled-as-html.sub-ref.html | 4 + .../corb/img-png-mislabeled-as-html.sub.html | 10 + ...g-svg-doctype-html-mimetype-empty.sub.html | 7 + ...img-svg-doctype-html-mimetype-svg.sub.html | 11 + .../fetch/corb/img-svg-invalid.sub-ref.html | 5 + .../corb/img-svg-labeled-as-dash.sub.html | 6 + .../corb/img-svg-labeled-as-svg-xml.sub.html | 6 + .../fetch/corb/img-svg-xml-decl.sub.html | 6 + .../wpt/tests/fetch/corb/img-svg.sub-ref.html | 5 + ...labeled-as-html-nosniff.tentative.sub.html | 24 + .../css-mislabeled-as-html-nosniff.css | 1 + ...css-mislabeled-as-html-nosniff.css.headers | 2 + .../corb/resources/css-mislabeled-as-html.css | 1 + .../css-mislabeled-as-html.css.headers | 1 + .../css-with-json-parser-breaker.css | 3 + .../corb/resources/empty-labeled-as-png.png | 0 .../empty-labeled-as-png.png.headers | 1 + .../resources/html-correctly-labeled.html | 10 + .../html-correctly-labeled.html.headers | 1 + .../fetch/corb/resources/html-js-polyglot.js | 9 + .../resources/html-js-polyglot.js.headers | 1 + .../fetch/corb/resources/html-js-polyglot2.js | 10 + .../resources/html-js-polyglot2.js.headers | 1 + .../js-mislabeled-as-html-nosniff.js | 1 + .../js-mislabeled-as-html-nosniff.js.headers | 2 + .../corb/resources/js-mislabeled-as-html.js | 1 + .../js-mislabeled-as-html.js.headers | 1 + .../corb/resources/png-correctly-labeled.png | Bin 0 -> 1010 bytes .../png-correctly-labeled.png.headers | 1 + .../png-mislabeled-as-html-nosniff.png | Bin 0 -> 1010 bytes ...png-mislabeled-as-html-nosniff.png.headers | 2 + .../corb/resources/png-mislabeled-as-html.png | Bin 0 -> 1010 bytes .../png-mislabeled-as-html.png.headers | 1 + .../corb/resources/response_block_probe.js | 1 + .../resources/response_block_probe.js.headers | 1 + .../corb/resources/sniffable-resource.py | 11 + ...ts-html-containing-blob-url-to-parent.html | 16 + .../svg-doctype-html-mimetype-empty.svg | 4 + ...vg-doctype-html-mimetype-empty.svg.headers | 1 + .../svg-doctype-html-mimetype-svg.svg | 4 + .../svg-doctype-html-mimetype-svg.svg.headers | 1 + .../corb/resources/svg-labeled-as-dash.svg | 3 + .../resources/svg-labeled-as-dash.svg.headers | 1 + .../corb/resources/svg-labeled-as-svg-xml.svg | 3 + .../svg-labeled-as-svg-xml.svg.headers | 1 + .../fetch/corb/resources/svg-xml-decl.svg | 4 + test/wpt/tests/fetch/corb/resources/svg.svg | 3 + .../fetch/corb/resources/svg.svg.headers | 1 + .../corb/response_block.tentative.https.html | 50 + ...-html-correctly-labeled.tentative.sub.html | 32 + .../corb/script-html-js-polyglot.sub.html | 32 + ...pt-html-via-cross-origin-blob-url.sub.html | 38 + ...ipt-js-mislabeled-as-html-nosniff.sub.html | 33 + .../script-js-mislabeled-as-html.sub.html | 25 + ...ith-json-parser-breaker.tentative.sub.html | 85 + ...with-nonsniffable-types.tentative.sub.html | 84 + ...le-css-mislabeled-as-html-nosniff.sub.html | 42 + .../style-css-mislabeled-as-html.sub.html | 36 + ...tyle-css-with-json-parser-breaker.sub.html | 38 + .../style-html-correctly-labeled.sub.html | 41 + .../fetch-in-iframe.html | 67 + .../cross-origin-resource-policy/fetch.any.js | 76 + .../fetch.https.any.js | 56 + .../iframe-loads.html | 46 + .../image-loads.html | 54 + .../resources/green.png | Bin 0 -> 87 bytes .../resources/hello.py | 6 + .../resources/iframe.py | 5 + .../resources/iframeFetch.html | 19 + .../resources/image.py | 22 + .../resources/redirect.py | 6 + .../resources/script.py | 6 + .../scheme-restriction.any.js | 7 + .../scheme-restriction.https.window.js | 13 + .../script-loads.html | 52 + .../syntax.any.js | 19 + test/wpt/tests/fetch/data-urls/README.md | 11 + test/wpt/tests/fetch/data-urls/base64.any.js | 18 + .../tests/fetch/data-urls/navigate.window.js | 75 + .../tests/fetch/data-urls/processing.any.js | 22 + .../fetch/data-urls/resources/base64.json | 82 + .../fetch/data-urls/resources/data-urls.json | 214 + test/wpt/tests/fetch/fetch-later/META.yml | 3 + test/wpt/tests/fetch/fetch-later/README.md | 3 + .../basic.tentative.https.window.js | 13 + .../fetch/fetch-later/non-secure.window.js | 8 + .../sendondiscard.tentative.https.window.js | 28 + test/wpt/tests/fetch/h1-parsing/README.md | 5 + .../tests/fetch/h1-parsing/lone-cr.window.js | 23 + .../resources-with-0x00-in-header.window.js | 31 + .../fetch/h1-parsing/resources/README.md | 6 + .../resources/blue-with-0x00-in-a-header.asis | Bin 0 -> 546 bytes .../resources/document-with-0x00-in-header.py | 4 + .../fetch/h1-parsing/resources/message.py | 3 + .../resources/script-with-0x00-in-header.py | 4 + .../fetch/h1-parsing/resources/status-code.py | 6 + .../fetch/h1-parsing/status-code.window.js | 98 + .../tests/fetch/http-cache/304-update.any.js | 146 + test/wpt/tests/fetch/http-cache/README.md | 72 + .../http-cache/basic-auth-cache-test-ref.html | 6 + .../http-cache/basic-auth-cache-test.html | 27 + .../tests/fetch/http-cache/cache-mode.any.js | 61 + .../tests/fetch/http-cache/cc-request.any.js | 202 + .../http-cache/credentials.tentative.any.js | 62 + .../tests/fetch/http-cache/freshness.any.js | 215 + .../tests/fetch/http-cache/heuristic.any.js | 93 + test/wpt/tests/fetch/http-cache/http-cache.js | 274 + .../tests/fetch/http-cache/invalidate.any.js | 235 + .../wpt/tests/fetch/http-cache/partial.any.js | 208 + .../tests/fetch/http-cache/post-patch.any.js | 46 + .../fetch/http-cache/resources/http-cache.py | 124 + .../http-cache/resources/securedimage.py | 19 + .../split-cache-popup-with-iframe.html | 34 + .../resources/split-cache-popup.html | 28 + .../tests/fetch/http-cache/split-cache.html | 158 + test/wpt/tests/fetch/http-cache/status.any.js | 60 + test/wpt/tests/fetch/http-cache/vary.any.js | 313 ++ ...vas-remote-read-remote-image-redirect.html | 28 + test/wpt/tests/fetch/metadata/META.yml | 4 + test/wpt/tests/fetch/metadata/README.md | 9 + .../fetch/metadata/audio-worklet.https.html | 20 + .../metadata/embed.https.sub.tentative.html | 63 + .../metadata/fetch-preflight.https.sub.any.js | 29 + .../fetch/metadata/fetch.https.sub.any.js | 58 + .../appcache-manifest.https.sub.html | 341 ++ .../generated/audioworklet.https.sub.html | 271 + .../css-font-face.https.sub.tentative.html | 230 + .../css-font-face.sub.tentative.html | 196 + .../css-images.https.sub.tentative.html | 1384 +++++ .../generated/css-images.sub.tentative.html | 1099 ++++ .../generated/element-a.https.sub.html | 482 ++ .../metadata/generated/element-a.sub.html | 342 ++ .../generated/element-area.https.sub.html | 482 ++ .../metadata/generated/element-area.sub.html | 342 ++ .../generated/element-audio.https.sub.html | 325 ++ .../metadata/generated/element-audio.sub.html | 229 + .../generated/element-embed.https.sub.html | 224 + .../metadata/generated/element-embed.sub.html | 190 + .../generated/element-frame.https.sub.html | 309 ++ .../metadata/generated/element-frame.sub.html | 250 + .../generated/element-iframe.https.sub.html | 309 ++ .../generated/element-iframe.sub.html | 250 + ...ment-img-environment-change.https.sub.html | 357 ++ .../element-img-environment-change.sub.html | 270 + .../generated/element-img.https.sub.html | 645 +++ .../metadata/generated/element-img.sub.html | 456 ++ .../element-input-image.https.sub.html | 229 + .../generated/element-input-image.sub.html | 184 + .../element-link-icon.https.sub.html | 371 ++ .../generated/element-link-icon.sub.html | 279 + ...ment-link-prefetch.https.optional.sub.html | 559 ++ .../element-link-prefetch.optional.sub.html | 275 + ...ement-meta-refresh.https.optional.sub.html | 276 + .../element-meta-refresh.optional.sub.html | 225 + .../generated/element-picture.https.sub.html | 997 ++++ .../generated/element-picture.sub.html | 721 +++ .../generated/element-script.https.sub.html | 593 ++ .../generated/element-script.sub.html | 488 ++ .../element-video-poster.https.sub.html | 243 + .../generated/element-video-poster.sub.html | 198 + .../generated/element-video.https.sub.html | 325 ++ .../metadata/generated/element-video.sub.html | 229 + .../fetch-via-serviceworker.https.sub.html | 683 +++ .../metadata/generated/fetch.https.sub.html | 302 + .../fetch/metadata/generated/fetch.sub.html | 220 + .../generated/form-submission.https.sub.html | 522 ++ .../generated/form-submission.sub.html | 400 ++ .../generated/header-link.https.sub.html | 529 ++ .../header-link.https.sub.tentative.html | 51 + .../metadata/generated/header-link.sub.html | 460 ++ .../header-refresh.https.optional.sub.html | 273 + .../header-refresh.optional.sub.html | 222 + ...cript-module-import-dynamic.https.sub.html | 254 + .../script-module-import-dynamic.sub.html | 214 + ...script-module-import-static.https.sub.html | 288 + .../script-module-import-static.sub.html | 246 + .../generated/serviceworker.https.sub.html | 170 + .../generated/svg-image.https.sub.html | 367 ++ .../metadata/generated/svg-image.sub.html | 265 + .../generated/window-history.https.sub.html | 237 + .../generated/window-history.sub.html | 360 ++ .../generated/window-location.https.sub.html | 1184 ++++ .../generated/window-location.sub.html | 894 +++ ...orker-dedicated-constructor.https.sub.html | 118 + .../worker-dedicated-constructor.sub.html | 204 + ...ker-dedicated-importscripts.https.sub.html | 268 + .../worker-dedicated-importscripts.sub.html | 228 + .../fetch/metadata/navigation.https.sub.html | 23 + .../fetch/metadata/object.https.sub.html | 62 + .../fetch/metadata/paint-worklet.https.html | 19 + .../fetch/metadata/portal.https.sub.html | 50 + .../fetch/metadata/preload.https.sub.html | 50 + ...-redirect-https-downgrade-upgrade.sub.html | 18 + .../redirect/redirect-http-upgrade.sub.html | 17 + .../redirect-https-downgrade.sub.html | 17 + .../fetch/metadata/report.https.sub.html | 33 + .../report.https.sub.html.sub.headers | 3 + .../resources/appcache-iframe.sub.html | 15 + .../metadata/resources/dedicatedWorker.js | 1 + .../fetch/metadata/resources/echo-as-json.py | 29 + .../metadata/resources/echo-as-script.py | 14 + .../fetch/metadata/resources/es-module.sub.js | 1 + .../fetch-via-serviceworker--fallback--sw.js | 3 + ...etch-via-serviceworker--respondWith--sw.js | 3 + .../fetch-via-serviceworker-frame.html | 3 + .../fetch/metadata/resources/header-link.py | 15 + .../tests/fetch/metadata/resources/helper.js | 42 + .../fetch/metadata/resources/helper.sub.js | 67 + .../metadata/resources/message-opener.html | 17 + .../fetch/metadata/resources/post-to-owner.py | 36 + .../fetch/metadata/resources/record-header.py | 145 + .../metadata/resources/record-headers.py | 73 + .../resources/redirectTestHelper.sub.js | 167 + .../serviceworker-accessors-frame.html | 3 + .../resources/serviceworker-accessors.sw.js | 14 + .../fetch/metadata/resources/sharedWorker.js | 9 + .../resources/unload-with-beacon.html | 12 + .../metadata/resources/xslt-test.sub.xml | 12 + .../serviceworker-accessors.https.sub.html | 51 + .../metadata/sharedworker.https.sub.html | 40 + .../tests/fetch/metadata/style.https.sub.html | 86 + test/wpt/tests/fetch/metadata/tools/README.md | 126 + .../metadata/tools/fetch-metadata.conf.yml | 806 +++ .../tests/fetch/metadata/tools/generate.py | 195 + .../appcache-manifest.sub.https.html | 63 + .../templates/audioworklet.https.sub.html | 53 + .../tools/templates/css-font-face.sub.html | 60 + .../tools/templates/css-images.sub.html | 137 + .../tools/templates/element-a.sub.html | 72 + .../tools/templates/element-area.sub.html | 72 + .../tools/templates/element-audio.sub.html | 51 + .../tools/templates/element-embed.sub.html | 54 + .../tools/templates/element-frame.sub.html | 62 + .../tools/templates/element-iframe.sub.html | 62 + .../element-img-environment-change.sub.html | 78 + .../tools/templates/element-img.sub.html | 52 + .../templates/element-input-image.sub.html | 48 + .../templates/element-link-icon.sub.html | 75 + .../element-link-prefetch.optional.sub.html | 71 + .../element-meta-refresh.optional.sub.html | 60 + .../tools/templates/element-picture.sub.html | 101 + .../tools/templates/element-script.sub.html | 54 + .../templates/element-video-poster.sub.html | 62 + .../tools/templates/element-video.sub.html | 51 + .../fetch-via-serviceworker.https.sub.html | 88 + .../metadata/tools/templates/fetch.sub.html | 42 + .../tools/templates/form-submission.sub.html | 87 + .../tools/templates/header-link.sub.html | 56 + .../header-refresh.optional.sub.html | 59 + .../script-module-import-dynamic.sub.html | 35 + .../script-module-import-static.sub.html | 53 + .../templates/serviceworker.https.sub.html | 72 + .../tools/templates/svg-image.sub.html | 75 + .../tools/templates/window-history.sub.html | 134 + .../tools/templates/window-location.sub.html | 128 + .../worker-dedicated-constructor.sub.html | 49 + .../worker-dedicated-importscripts.sub.html | 54 + .../tests/fetch/metadata/track.https.sub.html | 119 + .../metadata/trailing-dot.https.sub.any.js | 30 + .../fetch/metadata/unload.https.sub.html | 64 + .../fetch/metadata/window-open.https.sub.html | 199 + .../fetch/metadata/worker.https.sub.html | 24 + .../tests/fetch/metadata/xslt.https.sub.html | 25 + test/wpt/tests/fetch/nosniff/image.html | 39 + .../tests/fetch/nosniff/importscripts.html | 14 + test/wpt/tests/fetch/nosniff/importscripts.js | 28 + .../fetch/nosniff/parsing-nosniff.window.js | 27 + test/wpt/tests/fetch/nosniff/resources/css.py | 23 + .../tests/fetch/nosniff/resources/image.py | 24 + test/wpt/tests/fetch/nosniff/resources/js.py | 17 + .../tests/fetch/nosniff/resources/nosniff.py | 11 + .../tests/fetch/nosniff/resources/worker.py | 16 + .../resources/x-content-type-options.json | 62 + test/wpt/tests/fetch/nosniff/script.html | 43 + test/wpt/tests/fetch/nosniff/stylesheet.html | 60 + test/wpt/tests/fetch/nosniff/worker.html | 28 + test/wpt/tests/fetch/orb/resources/data.json | 3 + .../fetch/orb/resources/data_non_ascii.json | 1 + test/wpt/tests/fetch/orb/resources/empty.json | 1 + test/wpt/tests/fetch/orb/resources/font.ttf | Bin 0 -> 2528 bytes test/wpt/tests/fetch/orb/resources/image.png | Bin 0 -> 1010 bytes .../js-unlabeled-utf16-without-bom.json | Bin 0 -> 70 bytes .../tests/fetch/orb/resources/js-unlabeled.js | 1 + .../orb/resources/png-mislabeled-as-html.png | Bin 0 -> 1010 bytes .../png-mislabeled-as-html.png.headers | 1 + .../fetch/orb/resources/png-unlabeled.png | Bin 0 -> 1010 bytes .../orb/resources/script-asm-js-invalid.js | 4 + .../orb/resources/script-asm-js-valid.js | 4 + .../fetch/orb/resources/script-iso-8559-1.js | 4 + .../fetch/orb/resources/script-utf16-bom.js | Bin 0 -> 92 bytes .../orb/resources/script-utf16-without-bom.js | Bin 0 -> 90 bytes test/wpt/tests/fetch/orb/resources/script.js | 4 + test/wpt/tests/fetch/orb/resources/sound.mp3 | Bin 0 -> 539 bytes test/wpt/tests/fetch/orb/resources/text.txt | 1 + test/wpt/tests/fetch/orb/resources/utils.js | 18 + .../compressed-image-sniffing.sub.html | 20 + .../orb/tentative/content-range.sub.any.js | 31 + ...img-mime-types-coverage.tentative.sub.html | 126 + .../img-png-mislabeled-as-html.sub-ref.html | 5 + .../img-png-mislabeled-as-html.sub.html | 7 + .../tentative/img-png-unlabeled.sub-ref.html | 5 + .../orb/tentative/img-png-unlabeled.sub.html | 7 + .../orb/tentative/known-mime-type.sub.any.js | 86 + .../fetch/orb/tentative/nosniff.sub.any.js | 59 + .../script-js-unlabeled-gziped.sub.html | 24 + .../orb/tentative/script-unlabeled.sub.html | 24 + ...pt-utf16-without-bom-hint-charset.sub.html | 22 + .../fetch/orb/tentative/status.sub.any.js | 33 + .../tests/fetch/orb/tentative/status.sub.html | 17 + .../tentative/unknown-mime-type.sub.any.js | 28 + .../wpt/tests/fetch/origin/assorted.window.js | 211 + .../origin/resources/redirect-and-stash.py | 38 + .../fetch/origin/resources/referrer-policy.py | 7 + .../fetch/private-network-access/META.yml | 7 + .../fetch/private-network-access/README.md | 10 + ...eflight-required.tentative.https.window.js | 91 + ...ubresource-fetch.tentative.https.window.js | 330 ++ .../fenced-frame.tentative.https.window.js | 150 + ...-treat-as-public.tentative.https.window.js | 80 + .../fetch.tentative.https.window.js | 271 + .../fetch.tentative.window.js | 183 + .../iframe.tentative.https.window.js | 266 + .../iframe.tentative.window.js | 110 + ...ed-content-fetch.tentative.https.window.js | 277 + .../nested-worker.tentative.https.window.js | 36 + .../nested-worker.tentative.window.js | 36 + .../preflight-cache.https.tentative.window.js | 88 + .../redirect.tentative.https.window.js | 640 +++ .../resources/executor.html | 9 + .../resources/fenced-frame-fetcher.https.html | 25 + .../fenced-frame-fetcher.https.html.headers | 1 + ...ame-local-network-access-target.https.html | 8 + ...nced-frame-local-network-access.https.html | 14 + ...me-local-network-access.https.html.headers | 1 + .../resources/fetcher.html | 21 + .../resources/fetcher.js | 20 + .../resources/iframed.html | 7 + .../resources/iframer.html | 9 + .../resources/preflight.py | 175 + .../resources/service-worker-bridge.html | 155 + .../resources/service-worker.js | 18 + .../resources/shared-fetcher.js | 23 + .../resources/shared-worker-blob-fetcher.html | 50 + .../resources/shared-worker-fetcher.html | 19 + .../resources/socket-opener.html | 15 + .../resources/support.sub.js | 759 +++ .../resources/worker-blob-fetcher.html | 45 + .../resources/worker-fetcher.html | 18 + .../resources/worker-fetcher.js | 11 + .../resources/xhr-sender.html | 33 + ...background-fetch.tentative.https.window.js | 142 + ...ice-worker-fetch.tentative.https.window.js | 235 + ...ce-worker-update.tentative.https.window.js | 106 + .../service-worker.tentative.https.window.js | 84 + ...orker-blob-fetch.tentative.https.window.js | 168 + ...ared-worker-blob-fetch.tentative.window.js | 173 + ...red-worker-fetch.tentative.https.window.js | 167 + .../shared-worker-fetch.tentative.window.js | 154 + .../shared-worker.tentative.https.window.js | 34 + .../shared-worker.tentative.window.js | 34 + .../websocket.tentative.https.window.js | 40 + .../websocket.tentative.window.js | 40 + .../worker-blob-fetch.tentative.window.js | 155 + .../worker-fetch.tentative.https.window.js | 151 + .../worker-fetch.tentative.window.js | 154 + .../worker.tentative.https.window.js | 37 + .../worker.tentative.window.js | 37 + ...-treat-as-public.tentative.https.window.js | 83 + .../xhr.https.tentative.window.js | 142 + .../xhr.tentative.window.js | 195 + test/wpt/tests/fetch/range/blob.any.js | 233 + test/wpt/tests/fetch/range/data.any.js | 29 + test/wpt/tests/fetch/range/general.any.js | 140 + test/wpt/tests/fetch/range/general.window.js | 29 + .../range/non-matching-range-response.html | 34 + .../tests/fetch/range/resources/basic.html | 1 + .../tests/fetch/range/resources/long-wav.py | 134 + .../fetch/range/resources/partial-script.py | 29 + .../fetch/range/resources/partial-text.py | 53 + .../tests/fetch/range/resources/range-sw.js | 218 + .../tests/fetch/range/resources/stash-take.py | 7 + test/wpt/tests/fetch/range/resources/utils.js | 36 + .../fetch/range/resources/video-with-range.py | 43 + test/wpt/tests/fetch/range/sw.https.window.js | 228 + .../302-found-post-handler.py | 15 + .../redirect-navigate/302-found-post.html | 20 + .../redirect-navigate/preserve-fragment.html | 202 + .../resources/destination.html | 28 + test/wpt/tests/fetch/redirects/data.window.js | 25 + .../redirects/subresource-fragments.html | 39 + .../tests/fetch/security/1xx-response.any.js | 28 + ...kup-mitigation-data-url.tentative.sub.html | 229 + .../dangling-markup-mitigation.tentative.html | 147 + .../embedded-credentials.tentative.sub.html | 89 + ...edirect-to-url-with-credentials.https.html | 68 + .../embedded-credential-window.sub.html | 19 + .../fetch-sw.https.html | 65 + .../fetch/stale-while-revalidate/fetch.any.js | 32 + .../resources/stale-css.py | 28 + .../resources/stale-image.py | 40 + .../resources/stale-script.py | 32 + .../revalidate-not-blocked-by-csp.html | 69 + .../stale-while-revalidate/stale-css.html | 51 + .../stale-while-revalidate/stale-image.html | 55 + .../stale-while-revalidate/stale-script.html | 59 + .../stale-while-revalidate/sw-intercept.js | 14 + .../interfaces/ANGLE_instanced_arrays.idl | 12 + test/wpt/tests/interfaces/CSP.idl | 56 + test/wpt/tests/interfaces/DOM-Parsing.idl | 26 + .../wpt/tests/interfaces/EXT_blend_minmax.idl | 10 + .../interfaces/EXT_color_buffer_float.idl | 8 + .../EXT_color_buffer_half_float.idl | 12 + .../interfaces/EXT_disjoint_timer_query.idl | 30 + .../EXT_disjoint_timer_query_webgl2.idl | 14 + test/wpt/tests/interfaces/EXT_float_blend.idl | 8 + test/wpt/tests/interfaces/EXT_frag_depth.idl | 8 + test/wpt/tests/interfaces/EXT_sRGB.idl | 12 + .../interfaces/EXT_shader_texture_lod.idl | 8 + .../EXT_texture_compression_bptc.idl | 12 + .../EXT_texture_compression_rgtc.idl | 12 + .../EXT_texture_filter_anisotropic.idl | 10 + .../tests/interfaces/EXT_texture_norm16.idl | 16 + test/wpt/tests/interfaces/FedCM.idl | 67 + test/wpt/tests/interfaces/FileAPI.idl | 100 + test/wpt/tests/interfaces/IndexedDB.idl | 226 + .../KHR_parallel_shader_compile.idl | 9 + test/wpt/tests/interfaces/META.yml | 2 + .../interfaces/OES_draw_buffers_indexed.idl | 26 + .../interfaces/OES_element_index_uint.idl | 8 + .../interfaces/OES_fbo_render_mipmap.idl | 8 + .../interfaces/OES_standard_derivatives.idl | 9 + .../tests/interfaces/OES_texture_float.idl | 7 + .../interfaces/OES_texture_float_linear.idl | 7 + .../interfaces/OES_texture_half_float.idl | 9 + .../OES_texture_half_float_linear.idl | 7 + .../interfaces/OES_vertex_array_object.idl | 18 + test/wpt/tests/interfaces/OVR_multiview2.idl | 14 + test/wpt/tests/interfaces/README.md | 3 + test/wpt/tests/interfaces/SVG.idl | 693 +++ ...WEBGL_blend_equation_advanced_coherent.idl | 23 + .../interfaces/WEBGL_clip_cull_distance.idl | 20 + .../interfaces/WEBGL_color_buffer_float.idl | 11 + .../WEBGL_compressed_texture_astc.idl | 41 + .../WEBGL_compressed_texture_etc.idl | 19 + .../WEBGL_compressed_texture_etc1.idl | 10 + .../WEBGL_compressed_texture_pvrtc.idl | 13 + .../WEBGL_compressed_texture_s3tc.idl | 13 + .../WEBGL_compressed_texture_s3tc_srgb.idl | 13 + .../interfaces/WEBGL_debug_renderer_info.idl | 12 + .../tests/interfaces/WEBGL_debug_shaders.idl | 11 + .../tests/interfaces/WEBGL_depth_texture.idl | 9 + .../tests/interfaces/WEBGL_draw_buffers.idl | 46 + ...aw_instanced_base_vertex_base_instance.idl | 14 + .../tests/interfaces/WEBGL_lose_context.idl | 10 + .../wpt/tests/interfaces/WEBGL_multi_draw.idl | 32 + ...aw_instanced_base_vertex_base_instance.idl | 26 + .../interfaces/WEBGL_provoking_vertex.idl | 13 + test/wpt/tests/interfaces/WebCryptoAPI.idl | 237 + test/wpt/tests/interfaces/accelerometer.idl | 40 + test/wpt/tests/interfaces/ambient-light.idl | 14 + test/wpt/tests/interfaces/anchors.idl | 37 + .../interfaces/attribution-reporting-api.idl | 26 + test/wpt/tests/interfaces/audio-output.idl | 17 + .../tests/interfaces/autoplay-detection.idl | 19 + .../wpt/tests/interfaces/background-fetch.idl | 89 + test/wpt/tests/interfaces/background-sync.idl | 30 + test/wpt/tests/interfaces/badging.idl | 15 + test/wpt/tests/interfaces/battery-status.idl | 21 + test/wpt/tests/interfaces/beacon.idl | 8 + .../interfaces/capture-handle-identity.idl | 27 + .../captured-mouse-events.tentative.idl | 25 + test/wpt/tests/interfaces/clipboard-apis.idl | 51 + test/wpt/tests/interfaces/close-watcher.idl | 19 + test/wpt/tests/interfaces/compat.idl | 13 + test/wpt/tests/interfaces/compression.idl | 22 + .../wpt/tests/interfaces/compute-pressure.idl | 37 + test/wpt/tests/interfaces/console.idl | 34 + test/wpt/tests/interfaces/contact-picker.idl | 44 + test/wpt/tests/interfaces/content-index.idl | 46 + test/wpt/tests/interfaces/cookie-store.idl | 110 + .../interfaces/credential-management.idl | 105 + .../interfaces/csp-embedded-enforcement.idl | 8 + test/wpt/tests/interfaces/csp-next.idl | 21 + .../tests/interfaces/css-anchor-position.idl | 11 + .../interfaces/css-animation-worklet.idl | 37 + .../wpt/tests/interfaces/css-animations-2.idl | 9 + test/wpt/tests/interfaces/css-animations.idl | 47 + test/wpt/tests/interfaces/css-cascade-6.idl | 10 + test/wpt/tests/interfaces/css-cascade.idl | 14 + test/wpt/tests/interfaces/css-color-5.idl | 12 + test/wpt/tests/interfaces/css-conditional.idl | 27 + test/wpt/tests/interfaces/css-contain-3.idl | 10 + test/wpt/tests/interfaces/css-contain.idl | 13 + .../tests/interfaces/css-counter-styles.idl | 23 + .../wpt/tests/interfaces/css-font-loading.idl | 134 + test/wpt/tests/interfaces/css-fonts.idl | 36 + .../tests/interfaces/css-highlight-api.idl | 27 + test/wpt/tests/interfaces/css-images-4.idl | 8 + test/wpt/tests/interfaces/css-layout-api.idl | 144 + test/wpt/tests/interfaces/css-masking.idl | 20 + test/wpt/tests/interfaces/css-nav.idl | 48 + test/wpt/tests/interfaces/css-nesting.idl | 10 + test/wpt/tests/interfaces/css-paint-api.idl | 39 + test/wpt/tests/interfaces/css-parser-api.idl | 76 + .../interfaces/css-properties-values-api.idl | 23 + test/wpt/tests/interfaces/css-pseudo.idl | 16 + test/wpt/tests/interfaces/css-regions.idl | 29 + .../wpt/tests/interfaces/css-shadow-parts.idl | 8 + .../tests/interfaces/css-toggle.tentative.idl | 51 + .../tests/interfaces/css-transitions-2.idl | 9 + test/wpt/tests/interfaces/css-transitions.idl | 25 + test/wpt/tests/interfaces/css-typed-om.idl | 423 ++ .../tests/interfaces/css-view-transitions.idl | 18 + test/wpt/tests/interfaces/cssom-view.idl | 200 + test/wpt/tests/interfaces/cssom.idl | 169 + .../interfaces/custom-state-pseudo-class.idl | 14 + test/wpt/tests/interfaces/datacue.idl | 12 + .../interfaces/deprecation-reporting.idl | 15 + test/wpt/tests/interfaces/device-memory.idl | 14 + test/wpt/tests/interfaces/device-posture.idl | 20 + test/wpt/tests/interfaces/digital-goods.idl | 44 + .../document-picture-in-picture.idl | 34 + test/wpt/tests/interfaces/dom.idl | 646 +++ test/wpt/tests/interfaces/edit-context.idl | 111 + test/wpt/tests/interfaces/element-timing.idl | 22 + test/wpt/tests/interfaces/encoding.idl | 59 + test/wpt/tests/interfaces/encrypted-media.idl | 125 + test/wpt/tests/interfaces/entries-api.idl | 71 + test/wpt/tests/interfaces/event-timing.idl | 29 + test/wpt/tests/interfaces/eyedropper-api.idl | 18 + test/wpt/tests/interfaces/fenced-frame.idl | 57 + test/wpt/tests/interfaces/fetch.idl | 117 + test/wpt/tests/interfaces/fido.idl | 47 + .../tests/interfaces/file-system-access.idl | 72 + test/wpt/tests/interfaces/filter-effects.idl | 341 ++ .../wpt/tests/interfaces/font-metrics-api.idl | 42 + test/wpt/tests/interfaces/fs.idl | 97 + test/wpt/tests/interfaces/fullscreen.idl | 35 + .../tests/interfaces/gamepad-extensions.idl | 71 + test/wpt/tests/interfaces/gamepad.idl | 49 + test/wpt/tests/interfaces/generic-sensor.idl | 60 + .../tests/interfaces/geolocation-sensor.idl | 47 + test/wpt/tests/interfaces/geolocation.idl | 65 + test/wpt/tests/interfaces/geometry.idl | 290 + .../interfaces/get-installed-related-apps.idl | 16 + test/wpt/tests/interfaces/gpc-spec.idl | 10 + test/wpt/tests/interfaces/gyroscope.idl | 24 + test/wpt/tests/interfaces/hr-time.idl | 19 + .../tests/interfaces/html-media-capture.idl | 8 + test/wpt/tests/interfaces/html.idl | 2725 +++++++++ test/wpt/tests/interfaces/idle-detection.idl | 31 + test/wpt/tests/interfaces/image-capture.idl | 160 + test/wpt/tests/interfaces/image-resource.idl | 11 + test/wpt/tests/interfaces/ink-enhancement.idl | 32 + .../interfaces/input-device-capabilities.idl | 24 + test/wpt/tests/interfaces/input-events.idl | 14 + .../interfaces/intersection-observer.idl | 46 + .../interfaces/intervention-reporting.idl | 14 + .../wpt/tests/interfaces/is-input-pending.idl | 16 + .../tests/interfaces/js-self-profiling.idl | 44 + test/wpt/tests/interfaces/keyboard-lock.idl | 14 + test/wpt/tests/interfaces/keyboard-map.idl | 15 + .../interfaces/largest-contentful-paint.idl | 15 + .../tests/interfaces/layout-instability.idl | 20 + .../tests/interfaces/local-font-access.idl | 24 + test/wpt/tests/interfaces/longtasks.idl | 19 + test/wpt/tests/interfaces/magnetometer.idl | 46 + .../tests/interfaces/manifest-incubations.idl | 24 + test/wpt/tests/interfaces/mathml-core.idl | 9 + .../tests/interfaces/media-capabilities.idl | 115 + .../interfaces/media-playback-quality.idl | 18 + test/wpt/tests/interfaces/media-source.idl | 91 + .../interfaces/mediacapture-automation.idl | 36 + .../interfaces/mediacapture-fromelement.idl | 17 + .../mediacapture-handle-actions.idl | 31 + .../tests/interfaces/mediacapture-region.idl | 15 + .../tests/interfaces/mediacapture-streams.idl | 248 + .../interfaces/mediacapture-transform.idl | 23 + .../interfaces/mediacapture-viewport.idl | 14 + test/wpt/tests/interfaces/mediasession.idl | 84 + .../interfaces/mediastream-recording.idl | 62 + test/wpt/tests/interfaces/model-element.idl | 9 + .../wpt/tests/interfaces/mst-content-hint.idl | 18 + .../tests/interfaces/navigation-timing.idl | 71 + test/wpt/tests/interfaces/netinfo.idl | 43 + test/wpt/tests/interfaces/notifications.idl | 101 + .../tests/interfaces/orientation-event.idl | 78 + .../tests/interfaces/orientation-sensor.idl | 35 + test/wpt/tests/interfaces/page-lifecycle.idl | 19 + test/wpt/tests/interfaces/paint-timing.idl | 7 + .../tests/interfaces/parakeet.tentative.idl | 32 + test/wpt/tests/interfaces/payment-handler.idl | 131 + test/wpt/tests/interfaces/payment-request.idl | 112 + .../interfaces/performance-measure-memory.idl | 30 + .../tests/interfaces/performance-timeline.idl | 49 + .../interfaces/periodic-background-sync.idl | 34 + .../tests/interfaces/permissions-policy.idl | 29 + .../tests/interfaces/permissions-request.idl | 8 + .../tests/interfaces/permissions-revoke.idl | 8 + test/wpt/tests/interfaces/permissions.idl | 41 + .../tests/interfaces/picture-in-picture.idl | 41 + test/wpt/tests/interfaces/pointerevents.idl | 64 + test/wpt/tests/interfaces/pointerlock.idl | 28 + test/wpt/tests/interfaces/portals.idl | 50 + .../tests/interfaces/prefer-current-tab.idl | 8 + .../interfaces/prerendering-revamped.idl | 15 + .../wpt/tests/interfaces/presentation-api.idl | 95 + .../interfaces/private-click-measurement.idl | 8 + test/wpt/tests/interfaces/proximity.idl | 18 + test/wpt/tests/interfaces/push-api.idl | 93 + .../tests/interfaces/raw-camera-access.idl | 18 + .../tests/interfaces/real-world-meshing.idl | 21 + test/wpt/tests/interfaces/referrer-policy.idl | 16 + test/wpt/tests/interfaces/remote-playback.idl | 32 + test/wpt/tests/interfaces/reporting.idl | 39 + .../interfaces/requestStorageAccessFor.idl | 12 + .../tests/interfaces/requestidlecallback.idl | 20 + test/wpt/tests/interfaces/resize-observer.idl | 37 + test/wpt/tests/interfaces/resource-timing.idl | 42 + test/wpt/tests/interfaces/sanitizer-api.idl | 38 + .../interfaces/sanitizer-api.tentative.idl | 17 + test/wpt/tests/interfaces/savedata.idl | 10 + test/wpt/tests/interfaces/scheduling-apis.idl | 63 + test/wpt/tests/interfaces/screen-capture.idl | 85 + .../tests/interfaces/screen-orientation.idl | 35 + .../wpt/tests/interfaces/screen-wake-lock.idl | 24 + .../tests/interfaces/scroll-animations.idl | 46 + .../interfaces/scroll-to-text-fragment.idl | 12 + .../secure-payment-confirmation.idl | 52 + test/wpt/tests/interfaces/selection-api.idl | 46 + test/wpt/tests/interfaces/serial.idl | 85 + test/wpt/tests/interfaces/server-timing.idl | 17 + test/wpt/tests/interfaces/service-workers.idl | 240 + .../tests/interfaces/shape-detection-api.idl | 69 + test/wpt/tests/interfaces/shared-storage.idl | 80 + test/wpt/tests/interfaces/speech-api.idl | 202 + test/wpt/tests/interfaces/storage-access.idl | 9 + test/wpt/tests/interfaces/storage-buckets.idl | 53 + .../interfaces/storage-buckets.tentative.idl | 36 + test/wpt/tests/interfaces/storage.idl | 25 + test/wpt/tests/interfaces/streams.idl | 222 + .../tests/interfaces/sub-apps.tentative.idl | 17 + test/wpt/tests/interfaces/svg-animations.idl | 68 + test/wpt/tests/interfaces/testutils.idl | 9 + .../tests/interfaces/text-detection-api.idl | 18 + test/wpt/tests/interfaces/touch-events.idl | 79 + test/wpt/tests/interfaces/trust-token-api.idl | 26 + test/wpt/tests/interfaces/trusted-types.idl | 71 + test/wpt/tests/interfaces/turtledove.idl | 120 + test/wpt/tests/interfaces/ua-client-hints.idl | 45 + test/wpt/tests/interfaces/uievents.idl | 248 + test/wpt/tests/interfaces/url.idl | 46 + test/wpt/tests/interfaces/urlpattern.idl | 59 + test/wpt/tests/interfaces/user-timing.idl | 34 + test/wpt/tests/interfaces/vibration.idl | 10 + test/wpt/tests/interfaces/video-rvfc.idl | 27 + .../wpt/tests/interfaces/virtual-keyboard.idl | 21 + .../interfaces/virtual-keyboard.tentative.idl | 15 + test/wpt/tests/interfaces/wai-aria.idl | 59 + test/wpt/tests/interfaces/wasm-js-api.idl | 110 + test/wpt/tests/interfaces/wasm-web-api.idl | 11 + .../wpt/tests/interfaces/web-animations-2.idl | 112 + test/wpt/tests/interfaces/web-animations.idl | 149 + test/wpt/tests/interfaces/web-app-launch.idl | 19 + test/wpt/tests/interfaces/web-bluetooth.idl | 252 + test/wpt/tests/interfaces/web-locks.idl | 50 + test/wpt/tests/interfaces/web-nfc.idl | 81 + test/wpt/tests/interfaces/web-otp.idl | 21 + test/wpt/tests/interfaces/web-share.idl | 16 + test/wpt/tests/interfaces/webaudio.idl | 674 +++ test/wpt/tests/interfaces/webauthn.idl | 350 ++ .../webcodecs-aac-codec-registration.idl | 17 + .../webcodecs-av1-codec-registration.idl | 20 + .../webcodecs-avc-codec-registration.idl | 25 + .../webcodecs-flac-codec-registration.idl | 13 + .../webcodecs-hevc-codec-registration.idl | 17 + .../webcodecs-opus-codec-registration.idl | 22 + .../webcodecs-vp9-codec-registration.idl | 12 + test/wpt/tests/interfaces/webcodecs.idl | 501 ++ .../interfaces/webcrypto-secure-curves.idl | 8 + test/wpt/tests/interfaces/webdriver.idl | 9 + test/wpt/tests/interfaces/webgl1.idl | 745 +++ test/wpt/tests/interfaces/webgl2.idl | 582 ++ test/wpt/tests/interfaces/webgpu.idl | 1293 +++++ test/wpt/tests/interfaces/webhid.idl | 127 + test/wpt/tests/interfaces/webidl.idl | 48 + test/wpt/tests/interfaces/webmidi.idl | 91 + test/wpt/tests/interfaces/webnn.idl | 544 ++ .../interfaces/webrtc-encoded-transform.idl | 128 + test/wpt/tests/interfaces/webrtc-ice.idl | 24 + test/wpt/tests/interfaces/webrtc-identity.idl | 97 + test/wpt/tests/interfaces/webrtc-priority.idl | 24 + test/wpt/tests/interfaces/webrtc-stats.idl | 288 + test/wpt/tests/interfaces/webrtc-svc.idl | 8 + test/wpt/tests/interfaces/webrtc.idl | 627 +++ test/wpt/tests/interfaces/websockets.idl | 48 + test/wpt/tests/interfaces/webtransport.idl | 145 + test/wpt/tests/interfaces/webusb.idl | 249 + test/wpt/tests/interfaces/webvr.tentative.idl | 204 + test/wpt/tests/interfaces/webvtt.idl | 40 + test/wpt/tests/interfaces/webxr-ar-module.idl | 29 + .../tests/interfaces/webxr-depth-sensing.idl | 57 + .../tests/interfaces/webxr-dom-overlays.idl | 31 + .../interfaces/webxr-gamepads-module.idl | 8 + .../wpt/tests/interfaces/webxr-hand-input.idl | 66 + test/wpt/tests/interfaces/webxr-hit-test.idl | 69 + .../interfaces/webxr-lighting-estimation.idl | 39 + test/wpt/tests/interfaces/webxr.idl | 295 + test/wpt/tests/interfaces/webxrlayers.idl | 221 + .../interfaces/window-controls-overlay.idl | 28 + .../tests/interfaces/window-management.idl | 42 + test/wpt/tests/interfaces/xhr.idl | 99 + test/wpt/tests/lint.ignore | 707 +++ test/wpt/tests/mimesniff/META.yml | 3 + test/wpt/tests/mimesniff/README.md | 4 + .../mimesniff/media/media-sniff.window.js | 32 + .../tests/mimesniff/media/resources/flac.flac | Bin 0 -> 8493 bytes .../mimesniff/media/resources/make-vectors.sh | 10 + .../mimesniff/media/resources/mp3-raw.mp3 | Bin 0 -> 417 bytes .../media/resources/mp3-with-id3.mp3 | Bin 0 -> 644 bytes .../tests/mimesniff/media/resources/mp4.mp4 | Bin 0 -> 1231 bytes .../tests/mimesniff/media/resources/ogg.ogg | Bin 0 -> 3594 bytes .../tests/mimesniff/media/resources/wav.wav | Bin 0 -> 486 bytes .../tests/mimesniff/media/resources/webm.webm | Bin 0 -> 877 bytes test/wpt/tests/mimesniff/mime-types/README.md | 47 + .../mime-types/charset-parameter.window.js | 61 + .../tests/mimesniff/mime-types/parsing.any.js | 57 + .../resources/generated-mime-types.json | 3526 ++++++++++++ .../resources/generated-mime-types.py | 48 + .../mime-types/resources/mime-charset.py | 19 + .../mime-types/resources/mime-groups.json | 159 + .../mime-types/resources/mime-types.json | 397 ++ test/wpt/tests/resources/.htaccess | 2 + test/wpt/tests/resources/META.yml | 2 + .../SVGAnimationTestCase-testharness.js | 102 + test/wpt/tests/resources/accesskey.js | 34 + test/wpt/tests/resources/blank.html | 16 + test/wpt/tests/resources/channel.sub.js | 1097 ++++ test/wpt/tests/resources/check-layout-th.js | 252 + test/wpt/tests/resources/check-layout.js | 245 + test/wpt/tests/resources/chromium/README.md | 7 + .../chromium/contacts_manager_mock.js | 90 + .../chromium/content-index-helpers.js | 9 + .../chromium/enable-hyperlink-auditing.js | 2 + test/wpt/tests/resources/chromium/fake-hid.js | 297 + .../tests/resources/chromium/fake-serial.js | 443 ++ .../chromium/generic_sensor_mocks.js | 519 ++ .../chromium/generic_sensor_mocks.js.headers | 1 + .../chromium/mock-barcodedetection.js | 136 + .../chromium/mock-barcodedetection.js.headers | 1 + .../chromium/mock-battery-monitor.headers | 1 + .../chromium/mock-battery-monitor.js | 61 + .../resources/chromium/mock-direct-sockets.js | 94 + .../resources/chromium/mock-facedetection.js | 130 + .../chromium/mock-facedetection.js.headers | 1 + .../resources/chromium/mock-idle-detection.js | 80 + .../resources/chromium/mock-imagecapture.js | 309 ++ .../resources/chromium/mock-managed-config.js | 91 + .../chromium/mock-pressure-service.js | 134 + .../chromium/mock-pressure-service.js.headers | 1 + .../tests/resources/chromium/mock-subapps.js | 89 + .../resources/chromium/mock-textdetection.js | 92 + .../chromium/mock-textdetection.js.headers | 1 + test/wpt/tests/resources/chromium/nfc-mock.js | 437 ++ .../resources/chromium/web-bluetooth-test.js | 629 +++ .../chromium/web-bluetooth-test.js.headers | 1 + .../resources/chromium/webusb-child-test.js | 47 + .../chromium/webusb-child-test.js.headers | 1 + .../tests/resources/chromium/webusb-test.js | 583 ++ .../resources/chromium/webusb-test.js.headers | 1 + .../chromium/webxr-test-math-helper.js | 298 + .../webxr-test-math-helper.js.headers | 1 + .../tests/resources/chromium/webxr-test.js | 2125 +++++++ .../resources/chromium/webxr-test.js.headers | 1 + .../declarative-shadow-dom-polyfill.js | 25 + .../tests/resources/idlharness-shadowrealm.js | 61 + test/wpt/tests/resources/idlharness.js | 3554 ++++++++++++ .../wpt/tests/resources/idlharness.js.headers | 2 + test/wpt/tests/resources/readme.md | 14 + test/wpt/tests/resources/sriharness.js | 226 + test/wpt/tests/resources/test-only-api.js | 31 + .../tests/resources/test-only-api.js.headers | 2 + test/wpt/tests/resources/test-only-api.m.js | 5 + .../resources/test-only-api.m.js.headers | 2 + test/wpt/tests/resources/test/README.md | 83 + test/wpt/tests/resources/test/conftest.py | 269 + test/wpt/tests/resources/test/harness.html | 26 + test/wpt/tests/resources/test/idl-helper.js | 24 + .../resources/test/nested-testharness.js | 80 + .../wpt/tests/resources/test/requirements.txt | 1 + .../test/tests/functional/abortsignal.html | 49 + .../test/tests/functional/add_cleanup.html | 91 + .../tests/functional/add_cleanup_async.html | 85 + .../add_cleanup_async_bad_return.html | 50 + .../add_cleanup_async_rejection.html | 94 + ...dd_cleanup_async_rejection_after_load.html | 52 + .../functional/add_cleanup_async_timeout.html | 57 + .../functional/add_cleanup_bad_return.html | 61 + .../tests/functional/add_cleanup_count.html | 39 + .../tests/functional/add_cleanup_err.html | 45 + .../functional/add_cleanup_err_multi.html | 52 + .../functional/add_cleanup_sync_queue.html | 55 + .../test/tests/functional/api-tests-1.html | 991 ++++ .../test/tests/functional/api-tests-2.html | 62 + .../test/tests/functional/api-tests-3.html | 34 + .../tests/functional/assert-array-equals.html | 162 + .../tests/functional/assert-throws-dom.html | 55 + .../test/tests/functional/force_timeout.html | 60 + .../tests/functional/generate-callback.html | 153 + .../test_partial_interface_of.html | 89 + .../IdlInterface/test_exposed_wildcard.html | 233 + .../test_immutable_prototype.html | 298 + .../IdlInterface/test_interface_mixin.html | 131 + .../test_partial_interface_of.html | 187 + .../test_primary_interface_of.html | 116 + .../IdlInterface/test_to_json_operation.html | 177 + .../IdlNamespace/test_attribute.html | 100 + .../IdlNamespace/test_operation.html | 242 + .../IdlNamespace/test_partial_namespace.html | 125 + .../tests/functional/iframe-callback.html | 116 + .../functional/iframe-consolidate-errors.html | 50 + .../functional/iframe-consolidate-tests.html | 85 + .../test/tests/functional/iframe-msg.html | 84 + .../test/tests/functional/log-insertion.html | 46 + .../test/tests/functional/no-title.html | 146 + .../test/tests/functional/order.html | 36 + .../test/tests/functional/promise-async.html | 172 + .../tests/functional/promise-with-sync.html | 79 + .../test/tests/functional/promise.html | 219 + .../test/tests/functional/queue.html | 130 + .../tests/functional/setup-function-worker.js | 14 + .../functional/setup-worker-service.html | 86 + .../functional/single-page-test-fail.html | 28 + .../single-page-test-no-assertions.html | 25 + .../functional/single-page-test-no-body.html | 26 + .../functional/single-page-test-pass.html | 28 + .../test/tests/functional/step_wait.html | 57 + .../test/tests/functional/step_wait_func.html | 49 + .../task-scheduling-promise-test.html | 241 + .../functional/task-scheduling-test.html | 141 + .../functional/uncaught-exception-handle.html | 33 + .../functional/uncaught-exception-ignore.html | 35 + .../worker-dedicated-uncaught-allow.html | 45 + .../worker-dedicated-uncaught-single.html | 56 + .../functional/worker-dedicated.sub.html | 88 + .../test/tests/functional/worker-error.js | 8 + .../test/tests/functional/worker-service.html | 115 + .../test/tests/functional/worker-shared.html | 73 + .../tests/functional/worker-uncaught-allow.js | 19 + .../functional/worker-uncaught-single.js | 8 + .../resources/test/tests/functional/worker.js | 34 + .../tests/unit/IdlArray/is_json_type.html | 192 + .../get_reverse_inheritance_stack.html | 47 + .../test_partial_dictionary.html | 39 + .../tests/unit/IdlInterface/constructors.html | 26 + .../default_to_json_operation.html | 114 + .../do_member_unscopable_asserts.html | 56 + .../IdlInterface/get_interface_object.html | 22 + .../get_interface_object_owner.html | 21 + .../IdlInterface/get_legacy_namespace.html | 20 + .../unit/IdlInterface/get_qualified_name.html | 20 + .../get_reverse_inheritance_stack.html | 49 + ...has_default_to_json_regular_operation.html | 47 + .../has_to_json_regular_operation.html | 31 + .../should_have_interface_object.html | 30 + .../test_primary_interface_of_undefined.html | 29 + .../is_to_json_regular_operation.html | 42 + .../unit/IdlInterfaceMember/toString.html | 36 + .../test/tests/unit/assert_implements.html | 43 + .../unit/assert_implements_optional.html | 43 + .../test/tests/unit/assert_object_equals.html | 152 + .../unit/async-test-return-restrictions.html | 135 + .../resources/test/tests/unit/basic.html | 48 + .../unit/exceptional-cases-timeouts.html | 120 + .../test/tests/unit/exceptional-cases.html | 392 ++ .../test/tests/unit/format-value.html | 123 + .../resources/test/tests/unit/helpers.js | 21 + .../resources/test/tests/unit/late-test.html | 54 + .../tests/unit/promise_setup-timeout.html | 28 + .../test/tests/unit/promise_setup.html | 333 ++ .../test/tests/unit/single_test.html | 94 + .../tests/unit/test-return-restrictions.html | 156 + .../test/tests/unit/throwing-assertions.html | 268 + .../test/tests/unit/unpaired-surrogates.html | 143 + test/wpt/tests/resources/test/tox.ini | 13 + test/wpt/tests/resources/test/wptserver.py | 58 + .../wpt/tests/resources/testdriver-actions.js | 599 ++ test/wpt/tests/resources/testdriver-vendor.js | 0 .../resources/testdriver-vendor.js.headers | 2 + test/wpt/tests/resources/testdriver.js | 958 ++++ .../wpt/tests/resources/testdriver.js.headers | 2 + test/wpt/tests/resources/testharness.js | 4933 +++++++++++++++++ .../tests/resources/testharness.js.headers | 2 + test/wpt/tests/resources/testharnessreport.js | 57 + .../resources/testharnessreport.js.headers | 2 + test/wpt/tests/resources/webidl2/build.sh | 12 + .../wpt/tests/resources/webidl2/lib/README.md | 4 + .../tests/resources/webidl2/lib/VERSION.md | 1 + .../tests/resources/webidl2/lib/webidl2.js | 3824 +++++++++++++ .../resources/webidl2/lib/webidl2.js.headers | 1 + test/wpt/tests/service-workers/META.yml | 6 + .../service-workers/cache-storage/META.yml | 3 + .../cache-storage/cache-abort.https.any.js | 81 + .../cache-storage/cache-add.https.any.js | 368 ++ .../cache-storage/cache-delete.https.any.js | 164 + ...s-attributes-for-service-worker.https.html | 75 + .../cache-storage/cache-keys.https.any.js | 212 + .../cache-storage/cache-match.https.any.js | 437 ++ .../cache-storage/cache-matchAll.https.any.js | 244 + .../cache-storage/cache-put.https.any.js | 411 ++ .../cache-storage-buckets.https.any.js | 64 + .../cache-storage-keys.https.any.js | 35 + .../cache-storage-match.https.any.js | 245 + .../cache-storage/cache-storage.https.any.js | 239 + .../cache-storage/common.https.window.js | 44 + .../cache-response-clone.https.html | 17 + .../cache-storage/credentials.https.html | 46 + .../cross-partition.https.tentative.html | 269 + .../cache-storage/resources/blank.html | 2 + ...ache-keys-attributes-for-service-worker.js | 22 + .../cache-storage/resources/common-worker.js | 15 + .../resources/credentials-iframe.html | 38 + .../resources/credentials-worker.js | 59 + .../cache-storage/resources/fetch-status.py | 2 + .../cache-storage/resources/iframe.html | 18 + .../cache-storage/resources/simple.txt | 1 + .../cache-storage/resources/test-helpers.js | 272 + .../cache-storage/resources/vary.py | 25 + .../sandboxed-iframes.https.html | 66 + .../service-workers/idlharness.https.any.js | 53 + .../Service-Worker-Allowed-header.https.html | 88 + .../ServiceWorkerGlobalScope/close.https.html | 11 + ...dable-message-event-constructor.https.html | 10 + .../extendable-message-event.https.html | 226 + .../fetch-on-the-right-interface.https.any.js | 14 + .../isSecureContext.https.html | 32 + .../isSecureContext.serviceworker.js | 5 + .../postmessage.https.html | 83 + .../registration-attribute.https.html | 107 + .../resources/close-worker.js | 5 + .../resources/error-worker.js | 12 + ...ndable-message-event-constructor-worker.js | 197 + ...xtendable-message-event-loopback-worker.js | 36 + .../extendable-message-event-ping-worker.js | 23 + .../extendable-message-event-pong-worker.js | 18 + .../extendable-message-event-utils.js | 78 + .../extendable-message-event-worker.js | 5 + .../resources/postmessage-loopback-worker.js | 15 + .../resources/postmessage-ping-worker.js | 15 + .../resources/postmessage-pong-worker.js | 4 + .../registration-attribute-newer-worker.js | 33 + .../registration-attribute-worker.js | 139 + .../unregister-controlling-worker.html | 0 .../resources/unregister-worker.js | 25 + .../resources/update-worker.js | 22 + .../resources/update-worker.py | 16 + .../service-worker-error-event.https.html | 31 + .../unregister.https.html | 139 + .../update.https.html | 48 + .../about-blank-replacement.https.html | 181 + ...vent-after-install-state-change.https.html | 33 + .../activation-after-registration.https.html | 28 + .../service-worker/activation.https.html | 168 + .../service-worker/active.https.html | 50 + ...claim-affect-other-registration.https.html | 136 + .../service-worker/claim-fetch.https.html | 90 + .../claim-not-using-registration.https.html | 131 + .../claim-shared-worker-fetch.https.html | 71 + .../claim-using-registration.https.html | 103 + .../claim-with-redirect.https.html | 59 + .../claim-worker-fetch.https.html | 83 + .../service-worker/client-id.https.html | 60 + .../service-worker/client-navigate.https.html | 107 + .../client-url-of-blob-url-worker.https.html | 29 + .../clients-get-client-types.https.html | 108 + .../clients-get-cross-origin.https.html | 69 + .../clients-get-resultingClientId.https.html | 177 + .../service-worker/clients-get.https.html | 154 + ...lients-matchall-blob-url-worker.https.html | 85 + .../clients-matchall-client-types.https.html | 92 + ...ients-matchall-exact-controller.https.html | 67 + .../clients-matchall-frozen.https.html | 64 + ...s-matchall-include-uncontrolled.https.html | 117 + .../clients-matchall-on-evaluation.https.html | 24 + .../clients-matchall-order.https.html | 427 ++ .../clients-matchall.https.html | 50 + ...led-dedicatedworker-postMessage.https.html | 44 + .../controlled-iframe-postMessage.https.html | 67 + .../controller-on-disconnect.https.html | 40 + .../controller-on-load.https.html | 46 + .../controller-on-reload.https.html | 58 + ...ler-with-no-fetch-event-handler.https.html | 56 + .../service-worker/credentials.https.html | 100 + .../service-worker/data-iframe.html | 25 + .../data-transfer-files.https.html | 41 + ...ker-service-worker-interception.https.html | 40 + .../detached-context.https.html | 124 + ...-and-object-are-not-intercepted.https.html | 104 + ...xtendable-event-async-waituntil.https.html | 120 + .../extendable-event-waituntil.https.html | 140 + .../fetch-audio-tainting.https.html | 47 + ...ch-canvas-tainting-double-write.https.html | 57 + ...tch-canvas-tainting-image-cache.https.html | 16 + .../fetch-canvas-tainting-image.https.html | 16 + ...tch-canvas-tainting-video-cache.https.html | 17 + ...inting-video-with-range-request.https.html | 92 + .../fetch-canvas-tainting-video.https.html | 17 + ...fetch-cors-exposed-header-names.https.html | 30 + .../service-worker/fetch-cors-xhr.https.html | 49 + .../service-worker/fetch-csp.https.html | 138 + .../service-worker/fetch-error.https.html | 29 + .../fetch-event-add-async.https.html | 11 + ...nt-after-navigation-within-page.https.html | 71 + .../fetch-event-async-respond-with.https.html | 73 + .../fetch-event-handled.https.html | 86 + ...tory-backward-navigation-manual.https.html | 8 + ...story-forward-navigation-manual.https.html | 8 + ...reload-iframe-navigation-manual.https.html | 31 + ...ent-is-reload-navigation-manual.https.html | 8 + .../fetch-event-network-error.https.html | 44 + .../fetch-event-redirect.https.html | 1038 ++++ .../fetch-event-referrer-policy.https.html | 274 + ...tch-event-respond-with-argument.https.html | 44 + ...spond-with-body-loaded-in-chunk.https.html | 24 + ...nt-respond-with-custom-response.https.html | 82 + ...ent-respond-with-partial-stream.https.html | 62 + ...pond-with-readable-stream-chunk.https.html | 23 + ...nt-respond-with-readable-stream.https.html | 88 + ...esponse-body-with-invalid-chunk.https.html | 46 + ...-respond-with-stops-propagation.https.html | 37 + ...event-throws-after-respond-with.https.html | 37 + .../fetch-event-within-sw-manual.https.html | 122 + .../fetch-event-within-sw.https.html | 53 + .../service-worker/fetch-event.https.h2.html | 112 + .../service-worker/fetch-event.https.html | 1000 ++++ .../fetch-frame-resource.https.html | 236 + .../fetch-header-visibility.https.html | 54 + .../fetch-mixed-content-to-inscope.https.html | 21 + ...fetch-mixed-content-to-outscope.https.html | 21 + .../fetch-request-css-base-url.https.html | 87 + .../fetch-request-css-cross-origin.https.html | 81 + .../fetch-request-css-images.https.html | 214 + .../fetch-request-fallback.https.html | 282 + ...ch-request-no-freshness-headers.https.html | 55 + .../fetch-request-redirect.https.html | 385 ++ .../fetch-request-resources.https.html | 302 + ...tch-request-xhr-sync-error.https.window.js | 19 + ...etch-request-xhr-sync-on-worker.https.html | 41 + .../fetch-request-xhr-sync.https.html | 53 + .../fetch-request-xhr.https.html | 75 + .../fetch-response-taint.https.html | 223 + .../fetch-response-xhr.https.html | 50 + .../fetch-waits-for-activate.https.html | 128 + .../service-worker/getregistration.https.html | 108 + .../getregistrations.https.html | 134 + .../global-serviceworker.https.any.js | 53 + .../service-worker/historical.https.any.js | 5 + ...-to-https-redirect-and-register.https.html | 49 + ...mutable-prototype-serviceworker.https.html | 23 + .../import-scripts-cross-origin.https.html | 18 + .../import-scripts-data-url.https.html | 18 + .../import-scripts-mime-types.https.html | 30 + .../import-scripts-redirect.https.html | 55 + .../import-scripts-resource-map.https.html | 34 + .../import-scripts-updated-flag.https.html | 83 + .../service-worker/indexeddb.https.html | 78 + .../install-event-type.https.html | 30 + .../service-worker/installing.https.html | 48 + .../interface-requirements-sw.https.html | 16 + .../invalid-blobtype.https.html | 40 + .../service-worker/invalid-header.https.html | 39 + .../iso-latin1-header.https.html | 40 + .../local-url-inherit-controller.https.html | 115 + .../service-worker/mime-sniffing.https.html | 24 + .../multi-globals/current/current.https.html | 2 + .../multi-globals/current/test-sw.js | 1 + .../incumbent/incumbent.https.html | 20 + .../multi-globals/incumbent/test-sw.js | 1 + .../relevant/relevant.https.html | 2 + .../multi-globals/relevant/test-sw.js | 1 + .../service-worker/multi-globals/test-sw.js | 1 + .../multi-globals/url-parsing.https.html | 73 + .../service-worker/multipart-image.https.html | 68 + .../multiple-register.https.html | 117 + .../service-worker/multiple-update.https.html | 94 + .../service-worker/navigate-window.https.html | 151 + .../navigation-headers.https.html | 819 +++ .../broken-chunked-encoding.https.html | 42 + .../chunked-encoding.https.html | 25 + .../empty-preload-response-body.https.html | 25 + .../navigation-preload/get-state.https.html | 217 + .../navigationPreload.https.html | 20 + .../navigation-preload/redirect.https.html | 93 + .../request-headers.https.html | 41 + .../resource-timing.https.html | 92 + .../broken-chunked-encoding-scope.asis | 6 + .../broken-chunked-encoding-worker.js | 11 + .../resources/chunked-encoding-scope.py | 19 + .../resources/chunked-encoding-worker.js | 8 + .../navigation-preload/resources/cookie.py | 20 + .../empty-preload-response-body-scope.html | 0 .../empty-preload-response-body-worker.js | 15 + .../resources/get-state-worker.js | 21 + .../navigation-preload/resources/helpers.js | 5 + .../resources/navigation-preload-worker.js | 3 + .../resources/redirect-redirected.html | 3 + .../resources/redirect-scope.py | 38 + .../resources/redirect-worker.js | 35 + .../resources/request-headers-scope.py | 14 + .../resources/request-headers-worker.js | 10 + .../resources/resource-timing-scope.py | 19 + .../resources/resource-timing-worker.js | 37 + .../resources/samesite-iframe.html | 10 + .../resources/samesite-sw-helper.html | 34 + .../resources/samesite-worker.js | 8 + .../resources/wait-for-activate-worker.js | 40 + .../samesite-cookies.https.html | 61 + .../samesite-iframe.https.html | 67 + .../navigation-redirect-body.https.html | 53 + .../navigation-redirect-resolution.https.html | 58 + .../navigation-redirect-to-http.https.html | 25 + .../navigation-redirect.https.html | 846 +++ .../navigation-sets-cookie.https.html | 133 + .../navigation-timing-extended.https.html | 55 + .../navigation-timing.https.html | 77 + .../nested-blob-url-workers.https.html | 42 + .../next-hop-protocol.https.html | 49 + .../no-dynamic-import-in-module.any.js | 7 + .../service-worker/no-dynamic-import.any.js | 3 + .../onactivate-script-error.https.html | 74 + .../oninstall-script-error.https.html | 72 + .../opaque-response-preloaded.https.html | 50 + .../service-worker/opaque-script.https.html | 71 + .../partitioned-claim.tentative.https.html | 74 + .../partitioned-cookies.tentative.https.html | 119 + ...oned-getRegistrations.tentative.https.html | 99 + .../partitioned-matchAll.tentative.https.html | 65 + .../partitioned.tentative.https.html | 188 + .../performance-timeline.https.html | 49 + .../postMessage-client-worker.js | 23 + .../postmessage-blob-url.https.html | 33 + ...sage-from-waiting-serviceworker.https.html | 50 + .../postmessage-msgport-to-client.https.html | 43 + ...message-to-client-message-queue.https.html | 212 + .../postmessage-to-client.https.html | 42 + .../service-worker/postmessage.https.html | 202 + .../service-worker/ready.https.window.js | 223 + .../redirected-response.https.html | 471 ++ .../service-worker/referer.https.html | 40 + .../referrer-policy-header.https.html | 67 + .../referrer-toplevel-script-fetch.https.html | 64 + .../register-closed-window.https.html | 35 + .../register-default-scope.https.html | 69 + ...same-scope-different-script-url.https.html | 233 + ...-wait-forever-in-install-worker.https.html | 57 + .../registration-basic.https.html | 39 + .../registration-end-to-end.https.html | 88 + .../registration-events.https.html | 42 + .../registration-iframe.https.html | 116 + .../registration-mime-types.https.html | 10 + .../registration-schedule-job.https.html | 107 + ...tion-scope-module-static-import.https.html | 41 + .../registration-scope.https.html | 9 + .../registration-script-module.https.html | 13 + .../registration-script-url.https.html | 9 + .../registration-script.https.html | 12 + .../registration-security-error.https.html | 9 + ...ation-service-worker-attributes.https.html | 72 + .../registration-updateviacache.https.html | 204 + .../service-worker/rejections.https.html | 21 + .../request-end-to-end.https.html | 40 + .../resource-timing-bodySize.https.html | 55 + .../resource-timing-cross-origin.https.html | 46 + .../resource-timing-fetch-variants.https.html | 121 + .../resource-timing.sub.https.html | 150 + .../service-worker/resources/404.py | 5 + ...eplacement-blank-dynamic-nested-frame.html | 21 + ...-blank-replacement-blank-nested-frame.html | 21 + .../about-blank-replacement-frame.py | 31 + .../about-blank-replacement-ping-frame.py | 49 + .../about-blank-replacement-popup-frame.py | 32 + ...blank-replacement-srcdoc-nested-frame.html | 22 + ...replacement-uncontrolled-nested-frame.html | 22 + .../about-blank-replacement-worker.js | 95 + .../resources/basic-module-2.js | 1 + .../service-worker/resources/basic-module.js | 1 + .../service-worker/resources/blank.html | 2 + .../bytecheck-worker-imported-script.py | 20 + .../resources/bytecheck-worker.py | 38 + .../claim-blob-url-worker-fetch-iframe.html | 21 + .../claim-nested-worker-fetch-iframe.html | 16 + ...claim-nested-worker-fetch-parent-worker.js | 12 + .../claim-shared-worker-fetch-iframe.html | 13 + .../claim-shared-worker-fetch-worker.js | 8 + .../resources/claim-with-redirect-iframe.html | 48 + .../resources/claim-worker-fetch-iframe.html | 13 + .../resources/claim-worker-fetch-worker.js | 5 + .../service-worker/resources/claim-worker.js | 19 + .../resources/classic-worker.js | 1 + .../resources/client-id-worker.js | 27 + .../resources/client-navigate-frame.html | 12 + .../resources/client-navigate-worker.js | 92 + .../resources/client-navigated-frame.html | 3 + .../client-url-of-blob-url-worker.html | 26 + .../client-url-of-blob-url-worker.js | 10 + .../resources/clients-frame-freeze.html | 15 + .../clients-get-client-types-frame-worker.js | 11 + .../clients-get-client-types-frame.html | 17 + .../clients-get-client-types-shared-worker.js | 10 + .../clients-get-client-types-worker.js | 11 + .../clients-get-cross-origin-frame.html | 50 + .../resources/clients-get-frame.html | 12 + .../resources/clients-get-other-origin.html | 64 + .../clients-get-resultingClientId-worker.js | 60 + .../resources/clients-get-worker.js | 41 + .../clients-matchall-blob-url-worker.html | 20 + ...-matchall-client-types-dedicated-worker.js | 3 + .../clients-matchall-client-types-iframe.html | 8 + ...nts-matchall-client-types-shared-worker.js | 4 + .../clients-matchall-on-evaluation-worker.js | 11 + .../resources/clients-matchall-worker.js | 40 + .../controlled-frame-postMessage.html | 39 + .../controlled-worker-late-postMessage.js | 6 + .../controlled-worker-postMessage.js | 4 + .../resources/cors-approved.txt | 1 + .../resources/cors-approved.txt.headers | 3 + .../service-worker/resources/cors-denied.txt | 2 + .../resources/create-blob-url-worker.js | 22 + .../resources/create-out-of-scope-worker.html | 19 + .../service-worker/resources/echo-content.py | 16 + .../resources/echo-cookie-worker.py | 24 + .../echo-message-to-source-worker.js | 3 + ...d-and-object-are-not-intercepted-worker.js | 14 + ...embed-image-is-not-intercepted-iframe.html | 21 + .../embed-is-not-intercepted-iframe.html | 17 + ...-navigation-is-not-intercepted-iframe.html | 23 + .../embedded-content-from-server.html | 6 + .../embedded-content-from-service-worker.html | 7 + .../resources/empty-but-slow-worker.js | 8 + .../service-worker/resources/empty-worker.js | 1 + .../service-worker/resources/empty.h2.js | 0 .../service-worker/resources/empty.html | 6 + .../service-worker/resources/empty.js | 0 .../enable-client-message-queue.html | 39 + .../resources/end-to-end-worker.js | 7 + .../service-worker/resources/events-worker.js | 12 + .../extendable-event-async-waituntil.js | 210 + .../resources/extendable-event-waituntil.js | 87 + .../resources/fail-on-fetch-worker.js | 5 + .../resources/fetch-access-control-login.html | 16 + .../resources/fetch-access-control.py | 114 + ...tch-canvas-tainting-double-write-worker.js | 7 + .../fetch-canvas-tainting-iframe.html | 70 + .../resources/fetch-canvas-tainting-tests.js | 241 + .../fetch-cors-exposed-header-names-worker.js | 3 + .../resources/fetch-cors-xhr-iframe.html | 170 + .../resources/fetch-csp-iframe.html | 16 + .../fetch-csp-iframe.html.sub.headers | 1 + .../resources/fetch-error-worker.js | 22 + .../resources/fetch-event-add-async-worker.js | 6 + ...t-after-navigation-within-page-iframe.html | 22 + .../fetch-event-async-respond-with-worker.js | 66 + .../resources/fetch-event-handled-worker.js | 37 + ...event-network-error-controllee-iframe.html | 60 + .../fetch-event-network-error-worker.js | 49 + .../fetch-event-network-fallback-worker.js | 3 + ...ch-event-respond-with-argument-iframe.html | 55 + ...etch-event-respond-with-argument-worker.js | 14 + ...espond-with-body-loaded-in-chunk-worker.js | 7 + ...ent-respond-with-custom-response-worker.js | 45 + ...vent-respond-with-partial-stream-worker.js | 28 + ...spond-with-readable-stream-chunk-worker.js | 40 + ...ent-respond-with-readable-stream-worker.js | 81 + ...sponse-body-with-invalid-chunk-iframe.html | 15 + ...response-body-with-invalid-chunk-worker.js | 12 + ...t-respond-with-stops-propagation-worker.js | 15 + .../resources/fetch-event-test-worker.js | 224 + .../resources/fetch-event-within-sw-worker.js | 48 + .../fetch-header-visibility-iframe.html | 66 + ...xed-content-iframe-inscope-to-inscope.html | 71 + ...ed-content-iframe-inscope-to-outscope.html | 80 + .../resources/fetch-mixed-content-iframe.html | 71 + .../fetch-request-css-base-url-iframe.html | 20 + .../fetch-request-css-base-url-style.css | 1 + .../fetch-request-css-base-url-worker.js | 45 + ...uest-css-cross-origin-mime-check-cross.css | 1 + ...est-css-cross-origin-mime-check-cross.html | 1 + ...st-css-cross-origin-mime-check-iframe.html | 17 + ...quest-css-cross-origin-mime-check-same.css | 1 + ...uest-css-cross-origin-mime-check-same.html | 1 + ...equest-css-cross-origin-read-contents.html | 15 + .../fetch-request-css-cross-origin-worker.js | 65 + .../fetch-request-fallback-iframe.html | 32 + .../fetch-request-fallback-worker.js | 13 + .../fetch-request-html-imports-iframe.html | 13 + .../fetch-request-html-imports-worker.js | 30 + ...h-request-no-freshness-headers-iframe.html | 1 + ...tch-request-no-freshness-headers-script.py | 6 + ...tch-request-no-freshness-headers-worker.js | 18 + .../fetch-request-redirect-iframe.html | 35 + .../fetch-request-resources-iframe.https.html | 87 + .../fetch-request-resources-worker.js | 26 + .../fetch-request-xhr-iframe.https.html | 208 + .../fetch-request-xhr-sync-error-worker.js | 19 + .../fetch-request-xhr-sync-iframe.html | 13 + ...fetch-request-xhr-sync-on-worker-worker.js | 41 + .../fetch-request-xhr-sync-worker.js | 7 + .../resources/fetch-request-xhr-worker.js | 22 + .../fetch-response-taint-iframe.html | 2 + .../fetch-response-xhr-iframe.https.html | 53 + .../resources/fetch-response-xhr-worker.js | 12 + .../resources/fetch-response.html | 29 + .../resources/fetch-response.js | 35 + .../fetch-rewrite-worker-referrer-policy.js | 4 + ...-rewrite-worker-referrer-policy.js.headers | 2 + .../resources/fetch-rewrite-worker.js | 166 + .../resources/fetch-rewrite-worker.js.headers | 2 + .../resources/fetch-variants-worker.js | 35 + .../fetch-waits-for-activate-worker.js | 31 + .../service-worker/resources/form-poster.html | 13 + .../resources/frame-for-getregistrations.html | 19 + .../resources/get-resultingClientId-worker.js | 107 + ...to-https-redirect-and-register-iframe.html | 25 + .../resources/iframe-with-fetch-variants.html | 14 + .../resources/iframe-with-image.html | 2 + .../immutable-prototype-serviceworker.js | 19 + .../import-echo-cookie-worker-module.py | 6 + .../resources/import-echo-cookie-worker.js | 1 + .../resources/import-mime-type-worker.py | 10 + .../resources/import-relative.xsl | 5 + ...pts-404-after-update-plus-update-worker.js | 8 + .../import-scripts-404-after-update.js | 6 + .../resources/import-scripts-404.js | 1 + .../import-scripts-cross-origin-worker.sub.js | 1 + .../import-scripts-data-url-worker.js | 1 + ...import-scripts-diff-resource-map-worker.js | 10 + .../resources/import-scripts-echo.py | 6 + .../resources/import-scripts-get.py | 6 + .../import-scripts-mime-types-worker.js | 49 + .../import-scripts-redirect-import.js | 1 + ...-scripts-redirect-on-second-time-worker.js | 7 + .../import-scripts-redirect-worker.js | 1 + .../import-scripts-resource-map-worker.js | 15 + .../import-scripts-updated-flag-worker.js | 31 + .../resources/import-scripts-version.py | 17 + .../resources/imported-classic-script.js | 1 + .../resources/imported-module-script.js | 1 + .../resources/indexeddb-worker.js | 57 + .../resources/install-event-type-worker.js | 9 + .../resources/install-worker.html | 22 + .../interface-requirements-worker.sub.js | 59 + .../invalid-blobtype-iframe.https.html | 28 + .../resources/invalid-blobtype-worker.js | 10 + .../invalid-chunked-encoding-with-flush.py | 9 + .../resources/invalid-chunked-encoding.py | 2 + .../invalid-header-iframe.https.html | 25 + .../resources/invalid-header-worker.js | 12 + .../resources/iso-latin1-header-iframe.html | 23 + .../resources/iso-latin1-header-worker.js | 12 + .../service-worker/resources/load_worker.js | 29 + .../service-worker/resources/loaded.html | 9 + .../local-url-inherit-controller-frame.html | 130 + .../local-url-inherit-controller-worker.js | 5 + .../resources/location-setter.html | 10 + .../resources/malformed-http-response.asis | 1 + .../resources/malformed-worker.py | 14 + .../resources/message-vs-microtask.html | 18 + .../resources/mime-sniffing-worker.js | 9 + .../resources/mime-type-worker.py | 4 + .../resources/mint-new-worker.py | 27 + .../service-worker/resources/missing.asis | 4 + .../service-worker/resources/module-worker.js | 1 + .../resources/multipart-image-iframe.html | 19 + .../resources/multipart-image-worker.js | 21 + .../resources/multipart-image.py | 23 + .../resources/navigate-window-worker.js | 21 + .../resources/navigation-headers-server.py | 19 + .../navigation-redirect-body-worker.js | 11 + .../resources/navigation-redirect-body.py | 11 + .../navigation-redirect-other-origin.html | 89 + .../navigation-redirect-out-scope.py | 22 + .../resources/navigation-redirect-scope1.py | 22 + .../resources/navigation-redirect-scope2.py | 22 + .../navigation-redirect-to-http-iframe.html | 42 + .../navigation-redirect-to-http-worker.js | 22 + .../navigation-timing-worker-extended.js | 22 + .../resources/navigation-timing-worker.js | 15 + ...d-blob-url-worker-created-from-worker.html | 16 + .../resources/nested-blob-url-workers.html | 38 + .../resources/nested-iframe-parent.html | 5 + .../resources/nested-parent.html | 18 + ...d-worker-created-from-blob-url-worker.html | 33 + .../resources/nested_load_worker.js | 23 + .../resources/no-dynamic-import.js | 18 + .../resources/notification_icon.py | 11 + ...bject-image-is-not-intercepted-iframe.html | 21 + .../object-is-not-intercepted-iframe.html | 18 + ...-navigation-is-not-intercepted-iframe.html | 24 + ...te-throw-error-from-nested-event-worker.js | 13 + ...activate-throw-error-then-cancel-worker.js | 3 + ...throw-error-then-prevent-default-worker.js | 7 + ...e-throw-error-with-empty-onerror-worker.js | 2 + .../onactivate-throw-error-worker.js | 7 + .../resources/onactivate-waituntil-forever.js | 8 + .../resources/onfetch-waituntil-forever.js | 10 + ...ll-throw-error-from-nested-event-worker.js | 12 + ...ninstall-throw-error-then-cancel-worker.js | 3 + ...throw-error-then-prevent-default-worker.js | 7 + ...l-throw-error-with-empty-onerror-worker.js | 2 + .../resources/oninstall-throw-error-worker.js | 7 + .../resources/oninstall-waituntil-forever.js | 8 + .../oninstall-waituntil-throw-error-worker.js | 5 + .../resources/onparse-infiniteloop-worker.js | 8 + .../opaque-response-being-preloaded-xhr.html | 33 + .../opaque-response-preloaded-worker.js | 12 + .../opaque-response-preloaded-xhr.html | 35 + .../resources/opaque-script-frame.html | 21 + .../resources/opaque-script-large.js | 41 + .../resources/opaque-script-small.js | 3 + .../resources/opaque-script-sw.js | 37 + .../service-worker/resources/other.html | 3 + .../override_assert_object_equals.js | 58 + ...ioned-cookies-3p-credentialless-frame.html | 114 + .../partitioned-cookies-3p-frame.html | 108 + .../resources/partitioned-cookies-3p-sw.js | 53 + .../partitioned-cookies-3p-window.html | 35 + .../resources/partitioned-cookies-sw.js | 53 + ...rtitioned-service-worker-iframe-claim.html | 59 + ...ed-service-worker-nested-iframe-child.html | 44 + ...d-service-worker-nested-iframe-parent.html | 30 + ...r-third-party-iframe-getRegistrations.html | 40 + ...ce-worker-third-party-iframe-matchAll.html | 27 + ...ned-service-worker-third-party-iframe.html | 36 + ...ned-service-worker-third-party-window.html | 41 + .../resources/partitioned-storage-sw.js | 81 + .../resources/partitioned-utils.js | 110 + .../resources/pass-through-worker.js | 3 + .../service-worker/resources/pass.txt | 1 + .../resources/performance-timeline-worker.js | 62 + .../resources/postmessage-blob-url.js | 5 + ...message-dictionary-transferables-worker.js | 24 + .../resources/postmessage-echo-worker.js | 3 + .../resources/postmessage-fetched-text.js | 5 + .../postmessage-msgport-to-client-worker.js | 19 + .../resources/postmessage-on-load-worker.js | 9 + .../resources/postmessage-to-client-worker.js | 10 + .../postmessage-transferables-worker.js | 24 + .../resources/postmessage-worker.js | 19 + ...nge-request-to-different-origins-worker.js | 40 + ...equest-with-different-cors-modes-worker.js | 60 + .../resources/redirect-worker.js | 145 + .../service-worker/resources/redirect.py | 27 + .../resources/referer-iframe.html | 39 + .../resources/referrer-policy-iframe.html | 32 + .../register-closed-window-iframe.html | 19 + .../resources/register-iframe.html | 4 + .../resources/register-rewrite-worker.html | 32 + .../registration-tests-mime-types.js | 96 + .../resources/registration-tests-scope.js | 120 + .../registration-tests-script-url.js | 82 + .../resources/registration-tests-script.js | 121 + .../registration-tests-security-error.js | 78 + .../resources/registration-worker.js | 1 + .../resources/reject-install-worker.js | 3 + .../resources/reply-to-message.html | 7 + .../resources/request-end-to-end-worker.js | 34 + .../resources/request-headers.py | 8 + .../resources/resource-timing-iframe.sub.html | 10 + .../resources/resource-timing-worker.js | 12 + .../resources/respond-then-throw-worker.js | 40 + ...nd-with-body-accessed-response-iframe.html | 20 + ...pond-with-body-accessed-response-worker.js | 93 + .../respond-with-body-accessed-response.jsonp | 1 + .../resources/sample-worker-interceptor.js | 62 + .../service-worker/resources/sample.html | 2 + .../service-worker/resources/sample.js | 1 + .../service-worker/resources/sample.txt | 1 + .../sandboxed-iframe-fetch-event-iframe.html | 63 + .../sandboxed-iframe-fetch-event-iframe.py | 18 + .../sandboxed-iframe-fetch-event-worker.js | 20 + ...iframe-navigator-serviceworker-iframe.html | 25 + ...ule-worker-importing-redirect-to-scope2.js | 1 + .../scope1/module-worker-importing-scope2.js | 1 + .../resources/scope1/redirect.py | 6 + .../resources/scope2/import-scripts-echo.py | 6 + .../scope2/imported-module-script.js | 4 + .../resources/scope2/simple.txt | 1 + .../worker_interception_redirect_webworker.py | 6 + .../secure-context-service-worker.js | 21 + .../resources/secure-context/sender.html | 1 + .../resources/secure-context/window.html | 15 + .../resources/service-worker-csp-worker.py | 183 + .../resources/service-worker-header.py | 20 + ...rker-interception-dynamic-import-worker.js | 1 + ...vice-worker-interception-network-worker.js | 1 + ...vice-worker-interception-service-worker.js | 9 + ...orker-interception-static-import-worker.js | 1 + .../service-worker/resources/silence.oga | Bin 0 -> 12983 bytes .../resources/simple-intercept-worker.js | 5 + .../simple-intercept-worker.js.headers | 1 + .../service-worker/resources/simple.html | 3 + .../service-worker/resources/simple.txt | 1 + .../skip-waiting-installed-worker.js | 33 + .../resources/skip-waiting-worker.js | 21 + .../service-worker/resources/square.png | Bin 0 -> 18299 bytes .../resources/square.png.sub.headers | 2 + .../resources/stalling-service-worker.js | 54 + .../resources/subdir/blank.html | 2 + .../resources/subdir/import-scripts-echo.py | 6 + .../resources/subdir/simple.txt | 1 + .../worker_interception_redirect_webworker.py | 6 + .../service-worker/resources/success.py | 8 + .../svg-target-reftest-001-frame.html | 3 + .../resources/svg-target-reftest-001.html | 5 + .../resources/svg-target-reftest-frame.html | 2 + .../resources/test-helpers.sub.js | 300 + .../resources/test-request-headers-worker.js | 10 + .../resources/test-request-headers-worker.py | 21 + .../resources/test-request-mode-worker.js | 10 + .../resources/test-request-mode-worker.py | 22 + .../resources/testharness-helpers.js | 136 + .../service-worker/resources/trickle.py | 14 + .../resources/type-check-worker.js | 10 + .../resources/unregister-controller-page.html | 16 + .../unregister-immediately-helpers.js | 19 + .../resources/unregister-rewrite-worker.html | 18 + .../resources/update-claim-worker.py | 24 + .../update-during-installation-worker.js | 61 + .../update-during-installation-worker.py | 11 + .../resources/update-fetch-worker.py | 18 + .../update-max-aged-worker-imported-script.py | 14 + .../resources/update-max-aged-worker.py | 30 + ...-missing-import-scripts-imported-worker.py | 9 + ...date-missing-import-scripts-main-worker.py | 15 + .../resources/update-nocookie-worker.py | 14 + .../resources/update-recovery-worker.py | 25 + .../update-registration-with-type.py | 33 + ...update-smaller-body-after-update-worker.js | 1 + ...pdate-smaller-body-before-update-worker.js | 2 + .../resources/update-worker-from-file.py | 33 + .../service-worker/resources/update-worker.py | 62 + .../update/update-after-oneday.https.html | 8 + .../service-worker/resources/update_shell.py | 32 + .../service-worker/resources/vtt-frame.html | 6 + .../wait-forever-in-install-worker.js | 12 + .../resources/websocket-worker.js | 35 + .../service-worker/resources/websocket.js | 7 + .../resources/window-opener.html | 17 + .../resources/windowclient-navigate-worker.js | 75 + .../resources/worker-client-id-worker.js | 25 + .../resources/worker-fetching-cross-origin.js | 12 + ...ker-interception-redirect-serviceworker.js | 53 + .../worker-interception-redirect-webworker.js | 56 + .../resources/worker-load-interceptor.js | 16 + .../resources/worker-testharness.js | 49 + .../worker_interception_redirect_webworker.py | 20 + .../resources/xhr-content-length-worker.js | 22 + .../service-worker/resources/xhr-iframe.html | 23 + .../resources/xhr-response-url-worker.js | 32 + .../resources/xsl-base-url-iframe.xml | 5 + .../resources/xsl-base-url-worker.js | 12 + .../service-worker/resources/xslt-pass.xsl | 11 + ...ond-with-body-accessed-response.https.html | 54 + .../same-site-cookies.https.html | 496 ++ .../sandboxed-iframe-fetch-event.https.html | 536 ++ ...-iframe-navigator-serviceworker.https.html | 120 + .../service-worker/secure-context.https.html | 57 + .../service-worker-csp-connect.https.html | 10 + .../service-worker-csp-default.https.html | 10 + .../service-worker-csp-script.https.html | 10 + .../service-worker-header.https.html | 23 + ...worker-message-event-historical.https.html | 45 + .../serviceworkerobject-scripturl.https.html | 26 + .../skip-waiting-installed.https.html | 70 + ...skip-waiting-using-registration.https.html | 66 + .../skip-waiting-without-client.https.html | 12 + ...ting-without-using-registration.https.html | 44 + .../service-worker/skip-waiting.https.html | 58 + .../service-worker/state.https.html | 74 + .../svg-target-reftest.https.html | 28 + .../service-worker/synced-state.https.html | 93 + .../tentative/static-router/README.md | 4 + .../static-router/resources/direct.txt | 1 + ...mple-test-for-condition-main-resource.html | 3 + .../static-router/resources/simple.html | 3 + .../resources/static-router-sw.js | 35 + .../resources/test-helpers.sub.js | 303 + .../static-router-main-resource.https.html | 58 + .../static-router-subresource.https.html | 48 + .../uncontrolled-page.https.html | 39 + .../unregister-controller.https.html | 108 + ...er-immediately-before-installed.https.html | 57 + ...iately-during-extendable-events.https.html | 50 + .../unregister-immediately.https.html | 134 + ...gister-then-register-new-script.https.html | 136 + .../unregister-then-register.https.html | 107 + .../service-worker/unregister.https.html | 40 + ...te-after-navigation-fetch-event.https.html | 91 + ...pdate-after-navigation-redirect.https.html | 74 + .../update-after-oneday.https.html | 51 + .../update-bytecheck-cors-import.https.html | 92 + .../update-bytecheck.https.html | 92 + .../update-import-scripts.https.html | 135 + .../update-missing-import-scripts.https.html | 33 + .../update-module-request-mode.https.html | 45 + ...update-no-cache-request-headers.https.html | 48 + .../update-not-allowed.https.html | 140 + .../update-on-navigation.https.html | 20 + .../service-worker/update-recovery.https.html | 73 + .../update-registration-with-type.https.html | 208 + .../service-worker/update-result.https.html | 23 + .../service-worker/update.https.html | 164 + .../service-worker/waiting.https.html | 47 + .../websocket-in-service-worker.https.html | 27 + .../service-worker/websocket.https.html | 45 + .../webvtt-cross-origin.https.html | 175 + .../windowclient-navigate.https.html | 190 + .../worker-client-id.https.html | 58 + ...boxed-iframe-by-csp-fetch-event.https.html | 132 + .../worker-interception-redirect.https.html | 212 + .../worker-interception.https.html | 244 + .../xhr-content-length.https.window.js | 55 + .../xhr-response-url.https.html | 103 + .../service-worker/xsl-base-url.https.html | 32 + test/wpt/tests/storage/META.yml | 4 + test/wpt/tests/storage/README.md | 7 + test/wpt/tests/storage/buckets/META.yml | 5 + ...ket-quota-indexeddb.tentative.https.any.js | 35 + ...cket-storage-policy.tentative.https.any.js | 21 + .../buckets/resources/cached-resource.txt | 1 + .../tests/storage/buckets/resources/util.js | 57 + .../storage/estimate-indexeddb.https.any.js | 61 + .../storage/estimate-parallel.https.any.js | 13 + ...sage-details-caches.https.tentative.any.js | 20 + ...e-details-indexeddb.https.tentative.any.js | 59 + ...-service-workers.https.tentative.window.js | 38 + ...imate-usage-details.https.tentative.any.js | 12 + test/wpt/tests/storage/helpers.js | 46 + .../wpt/tests/storage/idlharness.https.any.js | 18 + .../storage/opaque-origin.https.window.js | 80 + ...ge-details-caches.tentative.https.sub.html | 74 + ...details-indexeddb.tentative.https.sub.html | 84 + ...s-service-workers.tentative.https.sub.html | 88 + .../storage/permission-query.https.any.js | 10 + .../persist-permission-manual.https.html | 27 + test/wpt/tests/storage/persisted.https.any.js | 14 + ...ge-in-detached-iframe.tentative.https.html | 21 + ...ate-usage-details-caches-helper-frame.html | 30 + ...-usage-details-indexeddb-helper-frame.html | 28 + ...-details-service-workers-helper-frame.html | 30 + test/wpt/tests/storage/resources/worker.js | 3 + .../storagemanager-estimate.https.any.js | 60 + ...nager-persist-persisted-match.https.any.js | 9 + .../storagemanager-persist.https.window.js | 10 + .../storagemanager-persist.https.worker.js | 8 + .../storagemanager-persisted.https.any.js | 10 + .../tests/websockets/Close-1000-reason.any.js | 21 + .../websockets/Close-1000-verify-code.any.js | 21 + test/wpt/tests/websockets/Close-1000.any.js | 21 + .../websockets/Close-1005-verify-code.any.js | 21 + test/wpt/tests/websockets/Close-1005.any.js | 18 + .../tests/websockets/Close-2999-reason.any.js | 17 + .../tests/websockets/Close-3000-reason.any.js | 21 + .../websockets/Close-3000-verify-code.any.js | 20 + .../tests/websockets/Close-4999-reason.any.js | 21 + .../websockets/Close-Reason-124Bytes.any.js | 20 + .../wpt/tests/websockets/Close-delayed.any.js | 27 + .../tests/websockets/Close-onlyReason.any.js | 17 + .../websockets/Close-readyState-Closed.any.js | 21 + .../Close-readyState-Closing.any.js | 20 + .../Close-reason-unpaired-surrogates.any.js | 22 + .../Close-server-initiated-close.any.js | 21 + .../tests/websockets/Close-undefined.any.js | 19 + .../Create-asciiSep-protocol-string.any.js | 12 + .../websockets/Create-blocked-port.any.js | 97 + .../websockets/Create-extensions-empty.any.js | 20 + .../tests/websockets/Create-http-urls.any.js | 19 + .../websockets/Create-invalid-urls.any.js | 14 + .../websockets/Create-non-absolute-url.any.js | 14 + .../Create-nonAscii-protocol-string.any.js | 12 + .../Create-on-worker-shutdown.any.js | 26 + .../Create-protocol-with-space.any.js | 11 + ...protocols-repeated-case-insensitive.any.js | 11 + .../Create-protocols-repeated.any.js | 11 + .../websockets/Create-url-with-space.any.js | 12 + ...Create-url-with-windows-1252-encoding.html | 20 + .../Create-valid-url-array-protocols.any.js | 21 + .../Create-valid-url-binaryType-blob.any.js | 21 + .../Create-valid-url-protocol-empty.any.js | 10 + ...ate-valid-url-protocol-setCorrectly.any.js | 21 + .../Create-valid-url-protocol-string.any.js | 21 + .../Create-valid-url-protocol.any.js | 21 + .../tests/websockets/Create-valid-url.any.js | 21 + test/wpt/tests/websockets/META.yml | 6 + test/wpt/tests/websockets/README.md | 1 + .../tests/websockets/Send-0byte-data.any.js | 30 + .../wpt/tests/websockets/Send-65K-data.any.js | 33 + .../tests/websockets/Send-before-open.any.js | 11 + .../Send-binary-65K-arraybuffer.any.js | 33 + .../websockets/Send-binary-arraybuffer.any.js | 33 + ...Send-binary-arraybufferview-float32.any.js | 40 + ...Send-binary-arraybufferview-float64.any.js | 40 + ...binary-arraybufferview-int16-offset.any.js | 40 + .../Send-binary-arraybufferview-int32.any.js | 40 + .../Send-binary-arraybufferview-int8.any.js | 40 + ...rraybufferview-uint16-offset-length.any.js | 40 + ...inary-arraybufferview-uint32-offset.any.js | 40 + ...arraybufferview-uint8-offset-length.any.js | 40 + ...binary-arraybufferview-uint8-offset.any.js | 40 + .../tests/websockets/Send-binary-blob.any.js | 36 + test/wpt/tests/websockets/Send-data.any.js | 30 + test/wpt/tests/websockets/Send-data.worker.js | 26 + test/wpt/tests/websockets/Send-null.any.js | 32 + .../websockets/Send-paired-surrogates.any.js | 30 + .../tests/websockets/Send-unicode-data.any.js | 30 + .../Send-unpaired-surrogates.any.js | 30 + ...socket-connection-ccns.tentative.window.js | 31 + ...with-closed-websocket-connection.window.js | 20 + ...socket-connection-ccns.tentative.window.js | 32 + ...e-with-open-websocket-connection.window.js | 21 + test/wpt/tests/websockets/basic-auth.any.js | 17 + test/wpt/tests/websockets/binary/001.html | 27 + test/wpt/tests/websockets/binary/002.html | 28 + test/wpt/tests/websockets/binary/004.html | 27 + test/wpt/tests/websockets/binary/005.html | 26 + .../websockets/binaryType-wrong-value.any.js | 23 + ...ufferedAmount-unchanged-by-sync-xhr.any.js | 25 + .../wpt/tests/websockets/close-invalid.any.js | 21 + .../websockets/closing-handshake/002.html | 23 + .../websockets/closing-handshake/003.html | 24 + .../websockets/closing-handshake/004.html | 25 + test/wpt/tests/websockets/constants.sub.js | 94 + test/wpt/tests/websockets/constructor.any.js | 10 + .../wpt/tests/websockets/constructor/001.html | 14 + .../wpt/tests/websockets/constructor/004.html | 36 + .../wpt/tests/websockets/constructor/005.html | 14 + .../wpt/tests/websockets/constructor/006.html | 29 + .../wpt/tests/websockets/constructor/007.html | 17 + .../wpt/tests/websockets/constructor/008.html | 15 + .../wpt/tests/websockets/constructor/009.html | 24 + .../wpt/tests/websockets/constructor/010.html | 22 + .../wpt/tests/websockets/constructor/011.html | 28 + .../wpt/tests/websockets/constructor/012.html | 20 + .../wpt/tests/websockets/constructor/013.html | 42 + .../wpt/tests/websockets/constructor/014.html | 39 + .../wpt/tests/websockets/constructor/016.html | 20 + .../wpt/tests/websockets/constructor/017.html | 19 + .../wpt/tests/websockets/constructor/018.html | 20 + .../wpt/tests/websockets/constructor/019.html | 21 + .../wpt/tests/websockets/constructor/020.html | 21 + .../wpt/tests/websockets/constructor/021.html | 12 + .../wpt/tests/websockets/constructor/022.html | 23 + test/wpt/tests/websockets/cookies/001.html | 28 + test/wpt/tests/websockets/cookies/002.html | 26 + test/wpt/tests/websockets/cookies/003.html | 34 + test/wpt/tests/websockets/cookies/004.html | 31 + test/wpt/tests/websockets/cookies/005.html | 35 + test/wpt/tests/websockets/cookies/006.html | 35 + test/wpt/tests/websockets/cookies/007.html | 36 + .../websockets/cookies/support/set-cookie.py | 7 + .../support/websocket-cookies-helper.sub.js | 57 + .../third-party-cookie-accepted.https.html | 25 + .../wpt/tests/websockets/eventhandlers.any.js | 15 + .../websockets/extended-payload-length.html | 72 + .../websockets/handlers/basic_auth_wsh.py | 26 + .../handlers/delayed-passive-close_wsh.py | 27 + .../websockets/handlers/echo-cookie_wsh.py | 12 + .../websockets/handlers/echo-query_v13_wsh.py | 11 + .../websockets/handlers/echo-query_wsh.py | 9 + .../handlers/echo_close_data_wsh.py | 20 + .../websockets/handlers/echo_exit_wsh.py | 19 + .../tests/websockets/handlers/echo_raw_wsh.py | 16 + .../wpt/tests/websockets/handlers/echo_wsh.py | 36 + .../websockets/handlers/empty-message_wsh.py | 13 + .../handlers/handshake_no_extensions_wsh.py | 9 + .../handlers/handshake_no_protocol_wsh.py | 8 + .../handlers/handshake_protocol_wsh.py | 7 + .../handlers/handshake_sleep_2_wsh.py | 9 + .../tests/websockets/handlers/invalid_wsh.py | 8 + .../websockets/handlers/msg_channel_wsh.py | 234 + .../tests/websockets/handlers/origin_wsh.py | 11 + .../websockets/handlers/protocol_array_wsh.py | 14 + .../tests/websockets/handlers/protocol_wsh.py | 12 + .../handlers/receive-backpressure_wsh.py | 14 + .../receive-many-with-backpressure_wsh.py | 23 + .../tests/websockets/handlers/referrer_wsh.py | 12 + .../handlers/send-backpressure_wsh.py | 39 + .../handlers/set-cookie-secure_wsh.py | 11 + .../handlers/set-cookie_http_wsh.py | 11 + .../websockets/handlers/set-cookie_wsh.py | 11 + .../handlers/set-cookies-samesite_wsh.py | 25 + .../handlers/simple_handshake_wsh.py | 35 + .../websockets/handlers/sleep_10_v13_wsh.py | 24 + .../handlers/stash_responder_blocking_wsh.py | 45 + .../handlers/stash_responder_wsh.py | 45 + .../handlers/wrong_accept_key_wsh.py | 19 + test/wpt/tests/websockets/idlharness.any.js | 17 + .../interfaces/CloseEvent/clean-close.html | 24 + .../interfaces/CloseEvent/constructor.html | 35 + .../interfaces/CloseEvent/historical.html | 12 + .../bufferedAmount-arraybuffer.html | 27 + .../bufferedAmount/bufferedAmount-blob.html | 28 + .../bufferedAmount-defineProperty-getter.html | 18 + .../bufferedAmount-defineProperty-setter.html | 20 + .../bufferedAmount-deleting.html | 23 + .../bufferedAmount-getting.html | 54 + .../bufferedAmount-initial.html | 15 + .../bufferedAmount/bufferedAmount-large.html | 29 + .../bufferedAmount-readonly.html | 16 + .../bufferedAmount-unicode.html | 25 + .../WebSocket/close/close-basic.html | 26 + .../WebSocket/close/close-connecting.html | 25 + .../WebSocket/close/close-multiple.html | 29 + .../WebSocket/close/close-nested.html | 28 + .../WebSocket/close/close-replace.html | 15 + .../WebSocket/close/close-return.html | 14 + .../interfaces/WebSocket/constants/001.html | 17 + .../interfaces/WebSocket/constants/002.html | 24 + .../interfaces/WebSocket/constants/003.html | 22 + .../interfaces/WebSocket/constants/004.html | 21 + .../interfaces/WebSocket/constants/005.html | 20 + .../interfaces/WebSocket/constants/006.html | 20 + .../interfaces/WebSocket/events/001.html | 18 + .../interfaces/WebSocket/events/002.html | 20 + .../interfaces/WebSocket/events/003.html | 21 + .../interfaces/WebSocket/events/004.html | 16 + .../interfaces/WebSocket/events/006.html | 17 + .../interfaces/WebSocket/events/007.html | 22 + .../interfaces/WebSocket/events/008.html | 24 + .../interfaces/WebSocket/events/009.html | 21 + .../interfaces/WebSocket/events/010.html | 21 + .../interfaces/WebSocket/events/011.html | 18 + .../interfaces/WebSocket/events/012.html | 18 + .../interfaces/WebSocket/events/013.html | 20 + .../interfaces/WebSocket/events/014.html | 21 + .../interfaces/WebSocket/events/015.html | 36 + .../interfaces/WebSocket/events/016.html | 39 + .../interfaces/WebSocket/events/017.html | 56 + .../interfaces/WebSocket/events/018.html | 52 + .../interfaces/WebSocket/events/019.html | 31 + .../interfaces/WebSocket/events/020.html | 17 + .../interfaces/WebSocket/extensions/001.html | 14 + .../WebSocket/protocol/protocol-initial.html | 14 + .../interfaces/WebSocket/readyState/001.html | 13 + .../interfaces/WebSocket/readyState/002.html | 15 + .../interfaces/WebSocket/readyState/003.html | 18 + .../interfaces/WebSocket/readyState/004.html | 17 + .../interfaces/WebSocket/readyState/005.html | 19 + .../interfaces/WebSocket/readyState/006.html | 19 + .../interfaces/WebSocket/readyState/007.html | 19 + .../interfaces/WebSocket/readyState/008.html | 21 + .../interfaces/WebSocket/send/001.html | 15 + .../interfaces/WebSocket/send/002.html | 15 + .../interfaces/WebSocket/send/003.html | 15 + .../interfaces/WebSocket/send/004.html | 25 + .../interfaces/WebSocket/send/005.html | 19 + .../interfaces/WebSocket/send/006.html | 28 + .../interfaces/WebSocket/send/007.html | 27 + .../interfaces/WebSocket/send/008.html | 25 + .../interfaces/WebSocket/send/009.html | 27 + .../interfaces/WebSocket/send/010.html | 42 + .../interfaces/WebSocket/send/011.html | 28 + .../interfaces/WebSocket/send/012.html | 28 + .../interfaces/WebSocket/url/001.html | 13 + .../interfaces/WebSocket/url/002.html | 15 + .../interfaces/WebSocket/url/003.html | 17 + .../interfaces/WebSocket/url/004.html | 17 + .../interfaces/WebSocket/url/005.html | 17 + .../interfaces/WebSocket/url/006.html | 19 + .../interfaces/WebSocket/url/resolve.html | 14 + .../keeping-connection-open/001.html | 29 + .../websockets/mixed-content.https.any.js | 7 + .../multi-globals/message-received.html | 33 + .../multi-globals/support/incumbent.sub.html | 24 + .../multi-globals/support/relevant.html | 2 + .../url-parsing/current/current.html | 2 + .../url-parsing/incumbent/incumbent.html | 13 + .../url-parsing/url-parsing.html | 22 + .../websockets/opening-handshake/001.html | 20 + .../websockets/opening-handshake/002.html | 24 + .../003-sets-origin.worker.js | 17 + .../websockets/opening-handshake/003.html | 27 + .../websockets/opening-handshake/005.html | 25 + test/wpt/tests/websockets/referrer.any.js | 13 + ...remove-own-iframe-during-onerror.window.js | 23 + .../resources/websockets-test-helpers.sub.js | 25 + test/wpt/tests/websockets/security/001.html | 16 + test/wpt/tests/websockets/security/002.html | 20 + test/wpt/tests/websockets/security/check.py | 2 + ...many-64K-messages-with-backpressure.any.js | 49 + .../websockets/stream/tentative/README.md | 9 + .../websockets/stream/tentative/abort.any.js | 50 + .../tentative/backpressure-receive.any.js | 40 + .../stream/tentative/backpressure-send.any.js | 25 + .../websockets/stream/tentative/close.any.js | 187 + .../stream/tentative/constructor.any.js | 67 + .../tentative/resources/url-constants.js | 8 + .../websockets/unload-a-document/001-1.html | 25 + .../websockets/unload-a-document/001-2.html | 4 + .../websockets/unload-a-document/001.html | 26 + .../websockets/unload-a-document/002-1.html | 32 + .../websockets/unload-a-document/002-2.html | 4 + .../websockets/unload-a-document/002.html | 27 + .../websockets/unload-a-document/003.html | 14 + .../websockets/unload-a-document/004.html | 16 + .../websockets/unload-a-document/005-1.html | 22 + .../websockets/unload-a-document/005.html | 21 + test/wpt/tests/wpt | 10 + test/wpt/tests/wpt.py | 7 + test/wpt/tests/xhr/META.yml | 7 + test/wpt/tests/xhr/README.md | 7 + .../xhr/XMLHttpRequest-withCredentials.any.js | 40 + test/wpt/tests/xhr/abort-after-receive.any.js | 30 + test/wpt/tests/xhr/abort-after-send.any.js | 29 + test/wpt/tests/xhr/abort-after-stop.window.js | 22 + test/wpt/tests/xhr/abort-after-timeout.any.js | 43 + .../wpt/tests/xhr/abort-during-done.window.js | 78 + .../abort-during-headers-received.window.js | 41 + .../tests/xhr/abort-during-loading.window.js | 41 + test/wpt/tests/xhr/abort-during-open.any.js | 18 + .../xhr/abort-during-readystatechange.any.js | 19 + test/wpt/tests/xhr/abort-during-unsent.any.js | 19 + test/wpt/tests/xhr/abort-during-upload.any.js | 17 + test/wpt/tests/xhr/abort-event-abort.any.js | 32 + .../tests/xhr/abort-event-listeners.any.js | 13 + test/wpt/tests/xhr/abort-event-loadend.any.js | 30 + test/wpt/tests/xhr/abort-event-order.htm | 52 + .../tests/xhr/abort-upload-event-abort.any.js | 31 + .../xhr/abort-upload-event-loadend.any.js | 31 + ...rol-and-redirects-async-same-origin.any.js | 61 + .../access-control-and-redirects-async.any.js | 79 + .../xhr/access-control-and-redirects.any.js | 50 + ...-access-control-origin-header-data-url.htm | 43 + ...-allow-access-control-origin-header.any.js | 13 + .../access-control-basic-allow-async.any.js | 19 + ...ow-non-cors-safelisted-method-async.any.js | 17 + ...ic-allow-non-cors-safelisted-method.any.js | 14 + ...flight-cache-invalidation-by-header.any.js | 38 + ...flight-cache-invalidation-by-method.any.js | 37 + ...basic-allow-preflight-cache-timeout.any.js | 37 + ...control-basic-allow-preflight-cache.any.js | 35 + .../access-control-basic-allow-star.any.js | 12 + .../xhr/access-control-basic-allow.any.js | 12 + ...-basic-cors-safelisted-request-headers.htm | 31 + ...basic-cors-safelisted-response-headers.htm | 32 + .../tests/xhr/access-control-basic-denied.htm | 30 + ...cess-control-basic-get-fail-non-simple.htm | 26 + ...basic-non-cors-safelisted-content-type.htm | 30 + ...rol-basic-post-success-no-content-type.htm | 26 + ...-with-non-cors-safelisted-content-type.htm | 37 + .../access-control-basic-preflight-denied.htm | 31 + ...ss-control-expose-headers-on-redirect.html | 33 + ...-control-preflight-async-header-denied.htm | 39 + ...-control-preflight-async-method-denied.htm | 38 + ...-control-preflight-async-not-supported.htm | 37 + ...ess-control-preflight-credential-async.htm | 29 + ...cess-control-preflight-credential-sync.htm | 24 + ...access-control-preflight-headers-async.htm | 35 + .../access-control-preflight-headers-sync.htm | 29 + ...-request-allow-headers-returns-star.any.js | 26 + ...rol-preflight-request-header-lowercase.htm | 29 + ...light-request-header-returns-origin.any.js | 26 + ...ontrol-preflight-request-header-sorted.htm | 28 + ...ntrol-preflight-request-headers-origin.htm | 29 + ...l-preflight-request-invalid-status-301.htm | 28 + ...l-preflight-request-invalid-status-400.htm | 28 + ...l-preflight-request-invalid-status-501.htm | 28 + ...flight-request-must-not-contain-cookie.htm | 57 + ...s-control-preflight-sync-header-denied.htm | 34 + ...s-control-preflight-sync-method-denied.htm | 33 + ...s-control-preflight-sync-not-supported.htm | 33 + ...ccess-control-recursive-failed-request.htm | 38 + ...access-control-response-with-body-sync.htm | 25 + .../xhr/access-control-response-with-body.htm | 29 + ...-control-response-with-exposed-headers.htm | 38 + ...rol-sandboxed-iframe-allow-origin-null.htm | 32 + .../access-control-sandboxed-iframe-allow.htm | 32 + ...ndboxed-iframe-denied-without-wildcard.htm | 43 + ...access-control-sandboxed-iframe-denied.htm | 41 + .../xhr/allow-lists-starting-with-comma.htm | 33 + .../tests/xhr/anonymous-mode-unsupported.htm | 40 + test/wpt/tests/xhr/blob-range.any.js | 246 + .../close-worker-with-xhr-in-progress.html | 26 + .../tests/xhr/content-type-unmodified.any.js | 16 + test/wpt/tests/xhr/cookies.http.html | 41 + .../wpt/tests/xhr/cors-expose-star.sub.any.js | 52 + test/wpt/tests/xhr/cors-upload.any.js | 59 + test/wpt/tests/xhr/data-uri.htm | 41 + test/wpt/tests/xhr/event-abort.any.js | 15 + test/wpt/tests/xhr/event-error-order.sub.html | 35 + test/wpt/tests/xhr/event-error.sub.any.js | 28 + test/wpt/tests/xhr/event-load.any.js | 21 + test/wpt/tests/xhr/event-loadend.any.js | 19 + .../tests/xhr/event-loadstart-upload.any.js | 19 + test/wpt/tests/xhr/event-loadstart.any.js | 17 + test/wpt/tests/xhr/event-progress.any.js | 18 + .../xhr/event-readystate-sync-open.any.js | 23 + .../xhr/event-readystatechange-loaded.any.js | 23 + test/wpt/tests/xhr/event-timeout-order.any.js | 21 + test/wpt/tests/xhr/event-timeout.any.js | 18 + .../event-upload-progress-crossorigin.any.js | 26 + .../tests/xhr/event-upload-progress.any.js | 30 + .../firing-events-http-content-length.html | 32 + .../firing-events-http-no-content-length.html | 35 + test/wpt/tests/xhr/folder.txt | 1 + test/wpt/tests/xhr/formdata.html | 90 + .../xhr/formdata/append-formelement.html | 52 + test/wpt/tests/xhr/formdata/append.any.js | 37 + .../xhr/formdata/constructor-formelement.html | 150 + .../xhr/formdata/constructor-submitter.html | 100 + .../wpt/tests/xhr/formdata/constructor.any.js | 6 + .../xhr/formdata/delete-formelement.html | 41 + test/wpt/tests/xhr/formdata/delete.any.js | 26 + test/wpt/tests/xhr/formdata/foreach.any.js | 56 + .../tests/xhr/formdata/get-formelement.html | 34 + test/wpt/tests/xhr/formdata/get.any.js | 28 + .../tests/xhr/formdata/has-formelement.html | 25 + test/wpt/tests/xhr/formdata/has.any.js | 19 + test/wpt/tests/xhr/formdata/iteration.any.js | 65 + test/wpt/tests/xhr/formdata/set-blob.any.js | 61 + .../tests/xhr/formdata/set-formelement.html | 51 + test/wpt/tests/xhr/formdata/set.any.js | 36 + .../xhr/getallresponseheaders-cookies.htm | 38 + .../xhr/getallresponseheaders-status.htm | 33 + test/wpt/tests/xhr/getallresponseheaders.htm | 35 + .../getresponseheader-case-insensitive.htm | 34 + .../xhr/getresponseheader-chunked-trailer.htm | 32 + .../getresponseheader-cookies-and-more.htm | 36 + .../xhr/getresponseheader-error-state.htm | 36 + .../xhr/getresponseheader-server-date.htm | 29 + .../getresponseheader-special-characters.htm | 34 + .../getresponseheader-unsent-opened-state.htm | 32 + test/wpt/tests/xhr/getresponseheader.any.js | 18 + .../wpt/tests/xhr/header-user-agent-async.htm | 26 + test/wpt/tests/xhr/header-user-agent-sync.htm | 20 + .../tests/xhr/headers-normalize-response.htm | 43 + test/wpt/tests/xhr/historical.html | 15 + test/wpt/tests/xhr/idlharness.any.js | 28 + test/wpt/tests/xhr/json.any.js | 23 + test/wpt/tests/xhr/loadstart-and-state.html | 40 + test/wpt/tests/xhr/open-after-abort.htm | 77 + .../tests/xhr/open-after-setrequestheader.htm | 33 + test/wpt/tests/xhr/open-after-stop.window.js | 43 + .../wpt/tests/xhr/open-during-abort-event.htm | 56 + .../xhr/open-during-abort-processing.htm | 62 + test/wpt/tests/xhr/open-during-abort.htm | 33 + test/wpt/tests/xhr/open-method-bogus.htm | 28 + .../xhr/open-method-case-insensitive.htm | 29 + .../tests/xhr/open-method-case-sensitive.htm | 31 + test/wpt/tests/xhr/open-method-insecure.htm | 29 + .../xhr/open-method-responsetype-set-sync.htm | 32 + test/wpt/tests/xhr/open-open-send.htm | 33 + test/wpt/tests/xhr/open-open-sync-send.htm | 31 + .../tests/xhr/open-parameters-toString.htm | 54 + test/wpt/tests/xhr/open-referer.htm | 20 + test/wpt/tests/xhr/open-send-during-abort.htm | 27 + test/wpt/tests/xhr/open-send-open.htm | 33 + test/wpt/tests/xhr/open-sync-open-send.htm | 41 + .../tests/xhr/open-url-about-blank-window.htm | 23 + .../xhr/open-url-base-inserted-after-open.htm | 24 + test/wpt/tests/xhr/open-url-base-inserted.htm | 24 + test/wpt/tests/xhr/open-url-base.htm | 22 + test/wpt/tests/xhr/open-url-encoding.htm | 26 + test/wpt/tests/xhr/open-url-fragment.htm | 38 + .../xhr/open-url-javascript-window-2.htm | 19 + .../tests/xhr/open-url-javascript-window.htm | 28 + .../wpt/tests/xhr/open-url-multi-window-2.htm | 25 + .../wpt/tests/xhr/open-url-multi-window-3.htm | 25 + .../wpt/tests/xhr/open-url-multi-window-4.htm | 50 + .../wpt/tests/xhr/open-url-multi-window-5.htm | 32 + .../wpt/tests/xhr/open-url-multi-window-6.htm | 41 + test/wpt/tests/xhr/open-url-multi-window.htm | 31 + ...pen-url-redirected-sharedworker-origin.htm | 11 + .../xhr/open-url-redirected-worker-origin.htm | 11 + test/wpt/tests/xhr/open-url-worker-origin.htm | 9 + test/wpt/tests/xhr/open-url-worker-simple.htm | 25 + .../open-user-password-non-same-origin.htm | 25 + test/wpt/tests/xhr/over-1-meg.any.js | 16 + test/wpt/tests/xhr/overridemimetype-blob.html | 57 + .../xhr/overridemimetype-done-state.any.js | 20 + .../xhr/overridemimetype-edge-cases.window.js | 50 + ...-headers-received-state-force-shiftjis.htm | 34 + .../overridemimetype-invalid-mime-type.htm | 41 + .../xhr/overridemimetype-loading-state.htm | 32 + ...verridemimetype-open-state-force-utf-8.htm | 27 + .../overridemimetype-open-state-force-xml.htm | 34 + ...imetype-unsent-state-force-shiftjis.any.js | 12 + .../xhr/preserve-ua-header-on-redirect.htm | 43 + .../progress-events-response-data-gzip.htm | 83 + .../tests/xhr/progressevent-constructor.html | 47 + .../tests/xhr/progressevent-interface.html | 49 + .../tests/xhr/request-content-length.any.js | 31 + .../tests/xhr/resources/accept-language.py | 3 + test/wpt/tests/xhr/resources/accept.py | 2 + .../resources/access-control-allow-lists.py | 26 + .../access-control-allow-with-body.py | 15 + .../resources/access-control-auth-basic.py | 17 + ...cess-control-basic-allow-no-credentials.py | 5 + .../access-control-basic-allow-star.py | 5 + .../resources/access-control-basic-allow.py | 6 + ...l-basic-cors-safelisted-request-headers.py | 16 + ...-basic-cors-safelisted-response-headers.py | 19 + .../resources/access-control-basic-denied.py | 5 + ...ess-control-basic-options-not-supported.py | 12 + ...trol-basic-preflight-cache-invalidation.py | 49 + ...s-control-basic-preflight-cache-timeout.py | 50 + .../access-control-basic-preflight-cache.py | 50 + .../access-control-basic-put-allow.py | 22 + .../xhr/resources/access-control-cookie.py | 16 + .../resources/access-control-origin-header.py | 8 + .../access-control-preflight-denied.py | 49 + ...ight-request-allow-headers-returns-star.py | 12 + ...trol-preflight-request-header-lowercase.py | 16 + ...preflight-request-header-returns-origin.py | 12 + ...control-preflight-request-header-sorted.py | 18 + ...ontrol-preflight-request-headers-origin.py | 12 + ...ontrol-preflight-request-invalid-status.py | 16 + ...eflight-request-must-not-contain-cookie.py | 12 + .../access-control-sandboxed-iframe.html | 24 + test/wpt/tests/xhr/resources/auth1/auth.py | 12 + test/wpt/tests/xhr/resources/auth10/auth.py | 12 + test/wpt/tests/xhr/resources/auth11/auth.py | 12 + test/wpt/tests/xhr/resources/auth2/auth.py | 12 + .../tests/xhr/resources/auth2/corsenabled.py | 18 + test/wpt/tests/xhr/resources/auth3/auth.py | 12 + test/wpt/tests/xhr/resources/auth4/auth.py | 12 + test/wpt/tests/xhr/resources/auth5/auth.py | 15 + test/wpt/tests/xhr/resources/auth6/auth.py | 15 + .../tests/xhr/resources/auth7/corsenabled.py | 20 + .../auth8/corsenabled-no-authorize.py | 20 + test/wpt/tests/xhr/resources/auth9/auth.py | 12 + .../wpt/tests/xhr/resources/authentication.py | 24 + .../tests/xhr/resources/bad-chunk-encoding.py | 17 + test/wpt/tests/xhr/resources/base.xml | 1 + test/wpt/tests/xhr/resources/chunked.py | 17 + test/wpt/tests/xhr/resources/conditional.py | 29 + test/wpt/tests/xhr/resources/content.py | 20 + test/wpt/tests/xhr/resources/corsenabled.py | 25 + test/wpt/tests/xhr/resources/delay.py | 7 + .../tests/xhr/resources/echo-content-cors.py | 23 + .../tests/xhr/resources/echo-content-type.py | 6 + test/wpt/tests/xhr/resources/echo-headers.py | 7 + test/wpt/tests/xhr/resources/echo-method.py | 16 + .../xhr/resources/empty-div-utf8-html.py | 5 + test/wpt/tests/xhr/resources/folder.txt | 1 + test/wpt/tests/xhr/resources/form.py | 2 + .../wpt/tests/xhr/resources/get-set-cookie.py | 18 + test/wpt/tests/xhr/resources/gzip.py | 24 + .../header-content-length-twice.asis | 3 + .../xhr/resources/header-content-length.asis | 2 + .../tests/xhr/resources/header-user-agent.py | 15 + .../tests/xhr/resources/headers-basic.asis | 4 + .../xhr/resources/headers-double-empty.asis | 3 + .../xhr/resources/headers-some-are-empty.asis | 7 + .../resources/headers-www-authenticate.asis | 4 + test/wpt/tests/xhr/resources/headers.asis | 6 + test/wpt/tests/xhr/resources/headers.py | 12 + test/wpt/tests/xhr/resources/image.gif | Bin 0 -> 167145 bytes test/wpt/tests/xhr/resources/img-utf8-html.py | 5 + test/wpt/tests/xhr/resources/img.jpg | Bin 0 -> 108761 bytes .../tests/xhr/resources/infinite-redirects.py | 24 + test/wpt/tests/xhr/resources/init.htm | 20 + .../tests/xhr/resources/inspect-headers.py | 36 + .../tests/xhr/resources/invalid-utf8-html.py | 5 + test/wpt/tests/xhr/resources/last-modified.py | 9 + .../no-custom-header-on-preflight.py | 27 + .../wpt/tests/xhr/resources/nocors/folder.txt | 1 + test/wpt/tests/xhr/resources/over-1-meg.txt | 1 + test/wpt/tests/xhr/resources/parse-headers.py | 6 + test/wpt/tests/xhr/resources/pass.txt | 1 + test/wpt/tests/xhr/resources/redirect-cors.py | 20 + test/wpt/tests/xhr/resources/redirect.py | 16 + test/wpt/tests/xhr/resources/requri.py | 5 + test/wpt/tests/xhr/resources/reset-token.py | 5 + .../responseType-document-in-worker.js | 9 + .../responseXML-unavailable-in-worker.js | 9 + ...after-setting-document-domain-window-1.htm | 23 + ...after-setting-document-domain-window-2.htm | 20 + ...r-setting-document-domain-window-helper.js | 32 + .../wpt/tests/xhr/resources/shift-jis-html.py | 6 + test/wpt/tests/xhr/resources/status.py | 11 + test/wpt/tests/xhr/resources/top.txt | 1 + test/wpt/tests/xhr/resources/trickle.py | 15 + test/wpt/tests/xhr/resources/upload.py | 17 + test/wpt/tests/xhr/resources/utf16-bom.json | Bin 0 -> 30 bytes test/wpt/tests/xhr/resources/utf16.txt | Bin 0 -> 18 bytes test/wpt/tests/xhr/resources/well-formed.xml | 4 + test/wpt/tests/xhr/resources/win-1252-html.py | 5 + test/wpt/tests/xhr/resources/win-1252-xml.py | 5 + .../resources/workerxhr-origin-referrer.js | 63 + .../tests/xhr/resources/workerxhr-simple.js | 9 + .../resources/xmlhttprequest-event-order.js | 83 + .../xmlhttprequest-timeout-aborted.js | 15 + .../xmlhttprequest-timeout-abortedonmain.js | 8 + .../xmlhttprequest-timeout-overrides.js | 12 + ...xmlhttprequest-timeout-overridesexpires.js | 12 + .../xmlhttprequest-timeout-runner.js | 21 + .../xmlhttprequest-timeout-simple.js | 6 + .../xmlhttprequest-timeout-synconmain.js | 2 + .../xmlhttprequest-timeout-synconworker.js | 11 + .../resources/xmlhttprequest-timeout-twice.js | 6 + .../xhr/resources/xmlhttprequest-timeout.js | 333 ++ test/wpt/tests/xhr/resources/zlib.py | 19 + .../wpt/tests/xhr/response-body-errors.any.js | 23 + .../tests/xhr/response-data-arraybuffer.htm | 54 + test/wpt/tests/xhr/response-data-blob.htm | 55 + test/wpt/tests/xhr/response-data-deflate.htm | 42 + test/wpt/tests/xhr/response-data-gzip.htm | 42 + test/wpt/tests/xhr/response-data-progress.htm | 52 + .../xhr/response-invalid-responsetype.htm | 38 + test/wpt/tests/xhr/response-json.htm | 61 + test/wpt/tests/xhr/response-method.htm | 21 + test/wpt/tests/xhr/responseText-status.html | 33 + .../xhr/responseType-document-in-worker.html | 13 + .../responseXML-unavailable-in-worker.html | 13 + .../tests/xhr/responsedocument-decoding.htm | 39 + test/wpt/tests/xhr/responsetext-decoding.htm | 93 + test/wpt/tests/xhr/responsetype.any.js | 135 + test/wpt/tests/xhr/responseurl.html | 37 + test/wpt/tests/xhr/responsexml-basic.htm | 33 + .../xhr/responsexml-document-properties.htm | 123 + test/wpt/tests/xhr/responsexml-get-twice.htm | 66 + .../tests/xhr/responsexml-invalid-type.html | 21 + test/wpt/tests/xhr/responsexml-media-type.htm | 41 + .../xhr/responsexml-non-document-types.htm | 45 + .../tests/xhr/responsexml-non-well-formed.htm | 30 + .../tests/xhr/security-consideration.sub.html | 36 + test/wpt/tests/xhr/send-accept-language.htm | 27 + test/wpt/tests/xhr/send-accept.htm | 24 + .../send-after-setting-document-domain.htm | 39 + ...-authentication-basic-cors-not-enabled.htm | 29 + .../xhr/send-authentication-basic-cors.htm | 35 + ...nd-authentication-basic-repeat-no-args.htm | 33 + ...n-basic-setrequestheader-and-arguments.htm | 36 + ...asic-setrequestheader-existing-session.htm | 53 + ...-authentication-basic-setrequestheader.htm | 36 + .../tests/xhr/send-authentication-basic.htm | 27 + ...thentication-competing-names-passwords.htm | 50 + ...entication-cors-basic-setrequestheader.htm | 31 + ...tication-cors-setrequestheader-no-cred.htm | 62 + ...authentication-existing-session-manual.htm | 33 + .../send-authentication-prompt-2-manual.htm | 25 + .../xhr/send-authentication-prompt-manual.htm | 25 + .../xhr/send-blob-with-no-mime-type.html | 61 + test/wpt/tests/xhr/send-conditional-cors.htm | 42 + test/wpt/tests/xhr/send-conditional.htm | 34 + .../tests/xhr/send-content-type-charset.htm | 115 + .../tests/xhr/send-content-type-string.htm | 26 + .../tests/xhr/send-data-arraybuffer.any.js | 31 + .../xhr/send-data-arraybufferview.any.js | 18 + test/wpt/tests/xhr/send-data-blob.htm | 62 + test/wpt/tests/xhr/send-data-es-object.any.js | 58 + test/wpt/tests/xhr/send-data-formdata.any.js | 21 + .../xhr/send-data-sharedarraybuffer.any.js | 27 + .../send-data-string-invalid-unicode.any.js | 46 + .../xhr/send-data-unexpected-tostring.htm | 56 + test/wpt/tests/xhr/send-entity-body-basic.htm | 28 + .../xhr/send-entity-body-document-bogus.htm | 26 + .../tests/xhr/send-entity-body-document.htm | 92 + test/wpt/tests/xhr/send-entity-body-empty.htm | 26 + .../xhr/send-entity-body-get-head-async.htm | 39 + .../tests/xhr/send-entity-body-get-head.htm | 36 + test/wpt/tests/xhr/send-entity-body-none.htm | 40 + .../send-network-error-async-events.sub.htm | 47 + .../send-network-error-sync-events.sub.htm | 45 + .../xhr/send-no-response-event-loadend.htm | 48 + .../xhr/send-no-response-event-loadstart.htm | 48 + .../xhr/send-no-response-event-order.htm | 45 + test/wpt/tests/xhr/send-non-same-origin.htm | 33 + test/wpt/tests/xhr/send-receive-utf16.htm | 37 + .../tests/xhr/send-redirect-bogus-sync.htm | 26 + test/wpt/tests/xhr/send-redirect-bogus.htm | 36 + .../tests/xhr/send-redirect-infinite-sync.htm | 24 + test/wpt/tests/xhr/send-redirect-infinite.htm | 35 + .../tests/xhr/send-redirect-no-location.htm | 40 + .../tests/xhr/send-redirect-post-upload.htm | 140 + test/wpt/tests/xhr/send-redirect-to-cors.htm | 92 + .../tests/xhr/send-redirect-to-non-cors.htm | 37 + test/wpt/tests/xhr/send-redirect.htm | 36 + .../tests/xhr/send-response-event-order.htm | 40 + .../send-response-upload-event-loadend.htm | 40 + .../send-response-upload-event-loadstart.htm | 39 + .../send-response-upload-event-progress.htm | 39 + test/wpt/tests/xhr/send-send.any.js | 7 + test/wpt/tests/xhr/send-sync-blocks-async.htm | 53 + .../xhr/send-sync-no-response-event-load.htm | 38 + .../send-sync-no-response-event-loadend.htm | 38 + .../xhr/send-sync-no-response-event-order.htm | 51 + .../xhr/send-sync-response-event-order.htm | 35 + test/wpt/tests/xhr/send-sync-timeout.htm | 29 + test/wpt/tests/xhr/send-timeout-events.htm | 62 + test/wpt/tests/xhr/send-usp.any.js | 46 + .../tests/xhr/setrequestheader-after-send.htm | 27 + .../setrequestheader-allow-empty-value.htm | 26 + ...equestheader-allow-whitespace-in-value.htm | 27 + .../xhr/setrequestheader-before-open.htm | 18 + .../tests/xhr/setrequestheader-bogus-name.htm | 59 + .../xhr/setrequestheader-bogus-value.htm | 36 + .../xhr/setrequestheader-case-insensitive.htm | 34 + .../xhr/setrequestheader-combining.window.js | 12 + .../xhr/setrequestheader-content-type.htm | 220 + .../xhr/setrequestheader-header-allowed.htm | 34 + .../xhr/setrequestheader-header-forbidden.htm | 95 + ...setrequestheader-open-setrequestheader.htm | 53 + test/wpt/tests/xhr/status-async.htm | 62 + test/wpt/tests/xhr/status-basic.htm | 51 + test/wpt/tests/xhr/status-error.htm | 87 + test/wpt/tests/xhr/status.h2.window.js | 21 + test/wpt/tests/xhr/sync-no-progress.any.js | 13 + test/wpt/tests/xhr/sync-no-timeout.any.js | 16 + .../tests/xhr/sync-xhr-and-window-onload.html | 25 + .../sync-xhr-supported-by-feature-policy.html | 11 + test/wpt/tests/xhr/template-element.html | 36 + .../wpt/tests/xhr/thrown-error-in-events.html | 60 + test/wpt/tests/xhr/timeout-cors-async.htm | 43 + .../tests/xhr/timeout-multiple-fetches.html | 32 + test/wpt/tests/xhr/timeout-sync.htm | 25 + .../xhr/xhr-authorization-redirect.any.js | 28 + .../wpt/tests/xhr/xhr-timeout-longtask.any.js | 14 + test/wpt/tests/xhr/xmlhttprequest-basic.htm | 45 + .../tests/xhr/xmlhttprequest-eventtarget.htm | 48 + .../xhr/xmlhttprequest-network-error-sync.htm | 34 + .../xhr/xmlhttprequest-network-error.htm | 39 + ...est-sync-block-defer-scripts-subframe.html | 17 + ...lhttprequest-sync-block-defer-scripts.html | 15 + .../xmlhttprequest-sync-block-scripts.html | 22 + ...quest-sync-default-feature-policy.sub.html | 32 + ...t-sync-not-hang-scriptloader-subframe.html | 17 + ...ttprequest-sync-not-hang-scriptloader.html | 16 + .../xhr/xmlhttprequest-timeout-aborted.html | 29 + .../xmlhttprequest-timeout-abortedonmain.html | 25 + .../xhr/xmlhttprequest-timeout-overrides.html | 26 + ...lhttprequest-timeout-overridesexpires.html | 26 + .../xhr/xmlhttprequest-timeout-reused.html | 49 + .../xhr/xmlhttprequest-timeout-simple.html | 27 + .../xmlhttprequest-timeout-synconmain.html | 23 + .../xhr/xmlhttprequest-timeout-twice.html | 28 + ...xmlhttprequest-timeout-worker-aborted.html | 31 + ...lhttprequest-timeout-worker-overrides.html | 27 + ...quest-timeout-worker-overridesexpires.html | 28 + .../xmlhttprequest-timeout-worker-simple.html | 29 + ...tprequest-timeout-worker-synconworker.html | 28 + .../xmlhttprequest-timeout-worker-twice.html | 29 + test/wpt/tests/xhr/xmlhttprequest-unsent.htm | 36 + types/README.md | 6 + types/agent.d.ts | 31 + types/api.d.ts | 43 + types/balanced-pool.d.ts | 18 + types/cache.d.ts | 36 + types/client.d.ts | 97 + types/connector.d.ts | 34 + types/content-type.d.ts | 21 + types/cookies.d.ts | 28 + types/diagnostics-channel.d.ts | 67 + types/dispatcher.d.ts | 241 + types/errors.d.ts | 128 + types/fetch.d.ts | 209 + types/file.d.ts | 39 + types/filereader.d.ts | 54 + types/formdata.d.ts | 108 + types/global-dispatcher.d.ts | 9 + types/global-origin.d.ts | 7 + types/handlers.d.ts | 9 + types/header.d.ts | 4 + types/index.d.ts | 65 + types/interceptors.d.ts | 5 + types/mock-agent.d.ts | 50 + types/mock-client.d.ts | 25 + types/mock-errors.d.ts | 12 + types/mock-interceptor.d.ts | 93 + types/mock-pool.d.ts | 25 + types/patch.d.ts | 71 + types/pool-stats.d.ts | 19 + types/pool.d.ts | 28 + types/proxy-agent.d.ts | 30 + types/readable.d.ts | 61 + types/retry-handler.d.ts | 116 + types/webidl.d.ts | 220 + types/websocket.d.ts | 131 + 3273 files changed, 268921 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/bench.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/nodejs.yml create mode 100644 .github/workflows/publish-undici-types.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .nojekyll create mode 100644 .npmignore create mode 100644 .taprc create mode 100644 CNAME create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 GOVERNANCE.md create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 benchmarks/benchmark-http2.js create mode 100644 benchmarks/benchmark-https.js create mode 100644 benchmarks/benchmark.js create mode 100644 benchmarks/fetch/bytes-match.mjs create mode 100644 benchmarks/server-http2.js create mode 100644 benchmarks/server-https.js create mode 100644 benchmarks/server.js create mode 100644 benchmarks/wait.js create mode 100644 build/Dockerfile create mode 100644 build/wasm.js create mode 100644 docs/api/Agent.md create mode 100644 docs/api/BalancedPool.md create mode 100644 docs/api/CacheStorage.md create mode 100644 docs/api/Client.md create mode 100644 docs/api/Connector.md create mode 100644 docs/api/ContentType.md create mode 100644 docs/api/Cookies.md create mode 100644 docs/api/DiagnosticsChannel.md create mode 100644 docs/api/DispatchInterceptor.md create mode 100644 docs/api/Dispatcher.md create mode 100644 docs/api/Errors.md create mode 100644 docs/api/Fetch.md create mode 100644 docs/api/MockAgent.md create mode 100644 docs/api/MockClient.md create mode 100644 docs/api/MockErrors.md create mode 100644 docs/api/MockPool.md create mode 100644 docs/api/Pool.md create mode 100644 docs/api/PoolStats.md create mode 100644 docs/api/ProxyAgent.md create mode 100644 docs/api/RetryHandler.md create mode 100644 docs/api/WebSocket.md create mode 100644 docs/api/api-lifecycle.md create mode 100644 docs/assets/lifecycle-diagram.png create mode 100644 docs/best-practices/client-certificate.md create mode 100644 docs/best-practices/mocking-request.md create mode 100644 docs/best-practices/proxy.md create mode 100644 docs/best-practices/writing-tests.md create mode 100644 docsify/sidebar.md create mode 100644 examples/ca-fingerprint/index.js create mode 100644 examples/fetch.js create mode 100644 examples/proxy-agent.js create mode 100644 examples/proxy/index.js create mode 100644 examples/proxy/proxy.js create mode 100644 examples/request.js create mode 100644 index-fetch.js create mode 100644 index.d.ts create mode 100644 index.html create mode 100644 index.js create mode 100644 lib/agent.js create mode 100644 lib/api/abort-signal.js create mode 100644 lib/api/api-connect.js create mode 100644 lib/api/api-pipeline.js create mode 100644 lib/api/api-request.js create mode 100644 lib/api/api-stream.js create mode 100644 lib/api/api-upgrade.js create mode 100644 lib/api/index.js create mode 100644 lib/api/readable.js create mode 100644 lib/api/util.js create mode 100644 lib/balanced-pool.js create mode 100644 lib/cache/cache.js create mode 100644 lib/cache/cachestorage.js create mode 100644 lib/cache/symbols.js create mode 100644 lib/cache/util.js create mode 100644 lib/client.js create mode 100644 lib/compat/dispatcher-weakref.js create mode 100644 lib/cookies/constants.js create mode 100644 lib/cookies/index.js create mode 100644 lib/cookies/parse.js create mode 100644 lib/cookies/util.js create mode 100644 lib/core/connect.js create mode 100644 lib/core/constants.js create mode 100644 lib/core/errors.js create mode 100644 lib/core/request.js create mode 100644 lib/core/symbols.js create mode 100644 lib/core/util.js create mode 100644 lib/dispatcher-base.js create mode 100644 lib/dispatcher.js create mode 100644 lib/fetch/LICENSE create mode 100644 lib/fetch/body.js create mode 100644 lib/fetch/constants.js create mode 100644 lib/fetch/dataURL.js create mode 100644 lib/fetch/file.js create mode 100644 lib/fetch/formdata.js create mode 100644 lib/fetch/global.js create mode 100644 lib/fetch/headers.js create mode 100644 lib/fetch/index.js create mode 100644 lib/fetch/request.js create mode 100644 lib/fetch/response.js create mode 100644 lib/fetch/symbols.js create mode 100644 lib/fetch/util.js create mode 100644 lib/fetch/webidl.js create mode 100644 lib/fileapi/encoding.js create mode 100644 lib/fileapi/filereader.js create mode 100644 lib/fileapi/progressevent.js create mode 100644 lib/fileapi/symbols.js create mode 100644 lib/fileapi/util.js create mode 100644 lib/global.js create mode 100644 lib/handler/DecoratorHandler.js create mode 100644 lib/handler/RedirectHandler.js create mode 100644 lib/handler/RetryHandler.js create mode 100644 lib/interceptor/redirectInterceptor.js create mode 100644 lib/llhttp/constants.d.ts create mode 100644 lib/llhttp/constants.js create mode 100644 lib/llhttp/utils.d.ts create mode 100644 lib/llhttp/utils.js create mode 100644 lib/llhttp/wasm_build_env.txt create mode 100644 lib/mock/mock-agent.js create mode 100644 lib/mock/mock-client.js create mode 100644 lib/mock/mock-errors.js create mode 100644 lib/mock/mock-interceptor.js create mode 100644 lib/mock/mock-pool.js create mode 100644 lib/mock/mock-symbols.js create mode 100644 lib/mock/mock-utils.js create mode 100644 lib/mock/pending-interceptors-formatter.js create mode 100644 lib/mock/pluralizer.js create mode 100644 lib/node/fixed-queue.js create mode 100644 lib/pool-base.js create mode 100644 lib/pool-stats.js create mode 100644 lib/pool.js create mode 100644 lib/proxy-agent.js create mode 100644 lib/timers.js create mode 100644 lib/websocket/connection.js create mode 100644 lib/websocket/constants.js create mode 100644 lib/websocket/events.js create mode 100644 lib/websocket/frame.js create mode 100644 lib/websocket/receiver.js create mode 100644 lib/websocket/symbols.js create mode 100644 lib/websocket/util.js create mode 100644 lib/websocket/websocket.js create mode 100644 package.json create mode 100644 scripts/generate-pem.js create mode 100644 scripts/generate-undici-types-package-json.js create mode 100644 scripts/verifyVersion.js create mode 100644 test/abort-controller.js create mode 100644 test/abort-event-emitter.js create mode 100644 test/agent.js create mode 100644 test/async_hooks.js create mode 100644 test/autoselectfamily.js create mode 100644 test/balanced-pool.js create mode 100644 test/ca-fingerprint.js create mode 100644 test/client-abort.js create mode 100644 test/client-connect.js create mode 100644 test/client-dispatch.js create mode 100644 test/client-errors.js create mode 100644 test/client-head-reset-override.js create mode 100644 test/client-idempotent-body.js create mode 100644 test/client-keep-alive.js create mode 100644 test/client-node-max-header-size.js create mode 100644 test/client-pipeline.js create mode 100644 test/client-pipelining.js create mode 100644 test/client-post.js create mode 100644 test/client-reconnect.js create mode 100644 test/client-request.js create mode 100644 test/client-stream.js create mode 100644 test/client-timeout.js create mode 100644 test/client-unref.js create mode 100644 test/client-upgrade.js create mode 100644 test/client-write-max-listeners.js create mode 100644 test/client.js create mode 100644 test/close-and-destroy.js create mode 100644 test/connect-abort.js create mode 100644 test/connect-errconnect.js create mode 100644 test/connect-timeout.js create mode 100644 test/content-length.js create mode 100644 test/cookie/cookies.js create mode 100644 test/cookie/global-headers.js create mode 100644 test/diagnostics-channel/connect-error.js create mode 100644 test/diagnostics-channel/error.js create mode 100644 test/diagnostics-channel/get.js create mode 100644 test/diagnostics-channel/post-stream.js create mode 100644 test/diagnostics-channel/post.js create mode 100644 test/dispatcher.js create mode 100644 test/errors.js create mode 100644 test/esm-wrapper.js create mode 100644 test/fetch/407-statuscode-window-null.js create mode 100644 test/fetch/abort.js create mode 100644 test/fetch/abort2.js create mode 100644 test/fetch/about-uri.js create mode 100644 test/fetch/blob-uri.js create mode 100644 test/fetch/bundle.js create mode 100644 test/fetch/client-error-stack-trace.js create mode 100644 test/fetch/client-fetch.js create mode 100644 test/fetch/client-node-max-header-size.js create mode 100644 test/fetch/content-length.js create mode 100644 test/fetch/cookies.js create mode 100644 test/fetch/data-uri.js create mode 100644 test/fetch/encoding.js create mode 100644 test/fetch/fetch-leak.js create mode 100644 test/fetch/fetch-timeouts.js create mode 100644 test/fetch/file.js create mode 100644 test/fetch/formdata.js create mode 100644 test/fetch/general.js create mode 100644 test/fetch/headers.js create mode 100644 test/fetch/http2.js create mode 100644 test/fetch/integrity.js create mode 100644 test/fetch/issue-1447.js create mode 100644 test/fetch/issue-2009.js create mode 100644 test/fetch/issue-2021.js create mode 100644 test/fetch/issue-2171.js create mode 100644 test/fetch/issue-2242.js create mode 100644 test/fetch/issue-2318.js create mode 100644 test/fetch/issue-node-46525.js create mode 100644 test/fetch/iterators.js create mode 100644 test/fetch/jsdom-abortcontroller-1910-1464495619.js create mode 100644 test/fetch/redirect-cross-origin-header.js create mode 100644 test/fetch/redirect.js create mode 100644 test/fetch/relative-url.js create mode 100644 test/fetch/request.js create mode 100644 test/fetch/resource-timing.js create mode 100644 test/fetch/response-json.js create mode 100644 test/fetch/response.js create mode 100644 test/fetch/user-agent.js create mode 100644 test/fetch/util.js create mode 100644 test/fixed-queue.js create mode 100644 test/fixtures/ca.pem create mode 100644 test/fixtures/cert.pem create mode 100644 test/fixtures/client-ca-crt.pem create mode 100644 test/fixtures/client-crt-2048.pem create mode 100644 test/fixtures/client-crt.pem create mode 100644 test/fixtures/client-key-2048.pem create mode 100644 test/fixtures/client-key.pem create mode 100644 test/fixtures/key.pem create mode 100644 test/fuzzing/client/client-fuzz-body.js create mode 100644 test/fuzzing/client/client-fuzz-headers.js create mode 100644 test/fuzzing/client/client-fuzz-options.js create mode 100644 test/fuzzing/client/index.js create mode 100644 test/fuzzing/fuzz.js create mode 100644 test/fuzzing/server/index.js create mode 100644 test/fuzzing/server/server-fuzz-append-data.js create mode 100644 test/fuzzing/server/server-fuzz-split-data.js create mode 100644 test/gc.js create mode 100644 test/get-head-body.js create mode 100644 test/headers-as-array.js create mode 100644 test/headers-crlf.js create mode 100644 test/http-100.js create mode 100644 test/http-req-destroy.js create mode 100644 test/http2-alpn.js create mode 100644 test/http2.js create mode 100644 test/https.js create mode 100644 test/imports/undici-import.ts create mode 100644 test/inflight-and-close.js create mode 100644 test/invalid-headers.js create mode 100644 test/issue-1670.js create mode 100644 test/issue-1903.js create mode 100644 test/issue-2065.js create mode 100644 test/issue-2078.js create mode 100644 test/issue-2349.js create mode 100644 test/issue-803.js create mode 100644 test/issue-810.js create mode 100644 test/jest/instanceof-error.test.js create mode 100644 test/jest/interceptor.test.js create mode 100644 test/jest/issue-1757.test.js create mode 100644 test/jest/mock-agent.test.js create mode 100644 test/jest/mock-scope.test.js create mode 100644 test/jest/test.js create mode 100644 test/max-headers.js create mode 100644 test/max-response-size.js create mode 100644 test/mock-agent.js create mode 100644 test/mock-client.js create mode 100644 test/mock-errors.js create mode 100644 test/mock-interceptor-unused-assertions.js create mode 100644 test/mock-interceptor.js create mode 100644 test/mock-pool.js create mode 100644 test/mock-scope.js create mode 100644 test/mock-utils.js create mode 100644 test/no-strict-content-length.js create mode 100644 test/node-fetch/LICENSE create mode 100644 test/node-fetch/headers.js create mode 100644 test/node-fetch/main.js create mode 100644 test/node-fetch/mock.js create mode 100644 test/node-fetch/request.js create mode 100644 test/node-fetch/response.js create mode 100644 test/node-fetch/utils/chai-timeout.js create mode 100644 test/node-fetch/utils/dummy.txt create mode 100644 test/node-fetch/utils/read-stream.js create mode 100644 test/node-fetch/utils/server.js create mode 100644 test/parser-issues.js create mode 100644 test/pipeline-pipelining.js create mode 100644 test/pool.js create mode 100644 test/promises.js create mode 100644 test/proxy-agent.js create mode 100644 test/proxy.js create mode 100644 test/readable.test.js create mode 100644 test/redirect-cross-origin-header.js create mode 100644 test/redirect-pipeline.js create mode 100644 test/redirect-relative.js create mode 100644 test/redirect-request.js create mode 100644 test/redirect-stream.js create mode 100644 test/redirect-upgrade.js create mode 100644 test/request-crlf.js create mode 100644 test/request-timeout.js create mode 100644 test/request-timeout2.js create mode 100644 test/request.js create mode 100644 test/retry-handler.js create mode 100644 test/socket-back-pressure.js create mode 100644 test/socket-timeout.js create mode 100644 test/stream-compat.js create mode 100644 test/tls-client-cert.js create mode 100644 test/tls-session-reuse.js create mode 100644 test/tls.js create mode 100644 test/trailers.js create mode 100644 test/types/agent.test-d.ts create mode 100644 test/types/api.test-d.ts create mode 100644 test/types/balanced-pool.test-d.ts create mode 100644 test/types/cache-storage.test-d.ts create mode 100644 test/types/client.test-d.ts create mode 100644 test/types/connector.test-d.ts create mode 100644 test/types/diagnostics-channel.test-d.ts create mode 100644 test/types/dispatcher.events.test-d.ts create mode 100644 test/types/dispatcher.test-d.ts create mode 100644 test/types/errors.test-d.ts create mode 100644 test/types/fetch.test-d.ts create mode 100644 test/types/formdata.test-d.ts create mode 100644 test/types/global-dispatcher.test-d.ts create mode 100644 test/types/header.test-d.ts create mode 100644 test/types/index.test-d.ts create mode 100644 test/types/interceptor.test-d.ts create mode 100644 test/types/mock-agent.test-d.ts create mode 100644 test/types/mock-client.test-d.ts create mode 100644 test/types/mock-errors.test-d.ts create mode 100644 test/types/mock-interceptor.test-d.ts create mode 100644 test/types/mock-pool.test-d.ts create mode 100644 test/types/pool.test-d.ts create mode 100644 test/types/proxy-agent.test-d.ts create mode 100644 test/types/readable.test-d.ts create mode 100644 test/unix.js create mode 100644 test/util.js create mode 100644 test/utils/async-iterators.js create mode 100644 test/utils/esm-wrapper.mjs create mode 100644 test/utils/formdata.js create mode 100644 test/utils/redirecting-servers.js create mode 100644 test/utils/stream.js create mode 100644 test/validations.js create mode 100644 test/webidl/converters.js create mode 100644 test/webidl/helpers.js create mode 100644 test/webidl/util.js create mode 100644 test/websocket/close.js create mode 100644 test/websocket/constructor.js create mode 100644 test/websocket/custom-headers.js create mode 100644 test/websocket/diagnostics-channel.js create mode 100644 test/websocket/events.js create mode 100644 test/websocket/fragments.js create mode 100644 test/websocket/frame.js create mode 100644 test/websocket/opening-handshake.js create mode 100644 test/websocket/ping-pong.js create mode 100644 test/websocket/receive.js create mode 100644 test/websocket/send.js create mode 100644 test/websocket/websocketinit.js create mode 100644 test/wpt/runner/runner.mjs create mode 100644 test/wpt/runner/util.mjs create mode 100644 test/wpt/runner/worker.mjs create mode 100644 test/wpt/server/routes/network-partition-key.mjs create mode 100644 test/wpt/server/routes/redirect.mjs create mode 100644 test/wpt/server/server.mjs create mode 100644 test/wpt/server/websocket.mjs create mode 100644 test/wpt/start-FileAPI.mjs create mode 100644 test/wpt/start-cacheStorage.mjs create mode 100644 test/wpt/start-fetch.mjs create mode 100644 test/wpt/start-mimesniff.mjs create mode 100644 test/wpt/start-websockets.mjs create mode 100644 test/wpt/start-xhr.mjs create mode 100644 test/wpt/status/FileAPI.status.json create mode 100644 test/wpt/status/fetch.status.json create mode 100644 test/wpt/status/mimesniff.status.json create mode 100644 test/wpt/status/service-workers/cache-storage.status.json create mode 100644 test/wpt/status/websockets.status.json create mode 100644 test/wpt/status/xhr/formdata.status.json create mode 100644 test/wpt/tests/.azure-pipelines.yml create mode 100644 test/wpt/tests/.gitattributes create mode 100644 test/wpt/tests/.gitignore create mode 100644 test/wpt/tests/.mailmap create mode 100644 test/wpt/tests/.taskcluster.yml create mode 100644 test/wpt/tests/CODEOWNERS create mode 100644 test/wpt/tests/CODE_OF_CONDUCT.md create mode 100644 test/wpt/tests/CONTRIBUTING.md create mode 100644 test/wpt/tests/FileAPI/Blob-methods-from-detached-frame.html create mode 100644 test/wpt/tests/FileAPI/BlobURL/cross-partition.tentative.https.html create mode 100644 test/wpt/tests/FileAPI/BlobURL/support/file_test2.txt create mode 100644 test/wpt/tests/FileAPI/BlobURL/test2-manual.html create mode 100644 test/wpt/tests/FileAPI/FileReader/progress_event_bubbles_cancelable.html create mode 100644 test/wpt/tests/FileAPI/FileReader/support/file_test1.txt create mode 100644 test/wpt/tests/FileAPI/FileReader/test_errors-manual.html create mode 100644 test/wpt/tests/FileAPI/FileReader/test_notreadableerrors-manual.html create mode 100644 test/wpt/tests/FileAPI/FileReader/test_securityerrors-manual.html create mode 100644 test/wpt/tests/FileAPI/FileReader/workers.html create mode 100644 test/wpt/tests/FileAPI/FileReaderSync.worker.js create mode 100644 test/wpt/tests/FileAPI/META.yml create mode 100644 test/wpt/tests/FileAPI/blob/Blob-array-buffer.any.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-constructor-dom.window.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-constructor-endings.html create mode 100644 test/wpt/tests/FileAPI/blob/Blob-constructor.any.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-in-worker.worker.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-slice-overflow.any.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-slice.any.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-stream-byob-crash.html create mode 100644 test/wpt/tests/FileAPI/blob/Blob-stream-sync-xhr-crash.html create mode 100644 test/wpt/tests/FileAPI/blob/Blob-stream.any.js create mode 100644 test/wpt/tests/FileAPI/blob/Blob-text.any.js create mode 100644 test/wpt/tests/FileAPI/file/File-constructor-endings.html create mode 100644 test/wpt/tests/FileAPI/file/File-constructor.any.js create mode 100644 test/wpt/tests/FileAPI/file/Worker-read-file-constructor.worker.js create mode 100644 test/wpt/tests/FileAPI/file/resources/echo-content-escaped.py create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-controls.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-iso-2022-jp.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-punctuation.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-utf-8.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-windows-1252.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form-x-user-defined.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-form.html create mode 100644 test/wpt/tests/FileAPI/file/send-file-formdata-controls.any.js create mode 100644 test/wpt/tests/FileAPI/file/send-file-formdata-punctuation.any.js create mode 100644 test/wpt/tests/FileAPI/file/send-file-formdata-utf-8.any.js create mode 100644 test/wpt/tests/FileAPI/file/send-file-formdata.any.js create mode 100644 test/wpt/tests/FileAPI/fileReader.any.js create mode 100644 test/wpt/tests/FileAPI/filelist-section/filelist.html create mode 100644 test/wpt/tests/FileAPI/filelist-section/filelist_multiple_selected_files-manual.html create mode 100644 test/wpt/tests/FileAPI/filelist-section/filelist_selected_file-manual.html create mode 100644 test/wpt/tests/FileAPI/filelist-section/support/upload.txt create mode 100644 test/wpt/tests/FileAPI/filelist-section/support/upload.zip create mode 100644 test/wpt/tests/FileAPI/historical.https.html create mode 100644 test/wpt/tests/FileAPI/idlharness-manual.html create mode 100644 test/wpt/tests/FileAPI/idlharness.any.js create mode 100644 test/wpt/tests/FileAPI/idlharness.html create mode 100644 test/wpt/tests/FileAPI/idlharness.worker.js create mode 100644 test/wpt/tests/FileAPI/progress-manual.html create mode 100644 test/wpt/tests/FileAPI/reading-data-section/Determining-Encoding.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/FileReader-event-handler-attributes.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/FileReader-multiple-reads.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_abort.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_error.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_events.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_file-manual.html create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_file_img-manual.html create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_readAsArrayBuffer.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_readAsBinaryString.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_readAsDataURL.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_readAsText.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_readystate.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/filereader_result.any.js create mode 100644 test/wpt/tests/FileAPI/reading-data-section/support/blue-100x100.png create mode 100644 test/wpt/tests/FileAPI/support/Blob.js create mode 100644 test/wpt/tests/FileAPI/support/document-domain-setter.sub.html create mode 100644 test/wpt/tests/FileAPI/support/empty-document.html create mode 100644 test/wpt/tests/FileAPI/support/historical-serviceworker.js create mode 100644 test/wpt/tests/FileAPI/support/incumbent.sub.html create mode 100644 test/wpt/tests/FileAPI/support/send-file-form-helper.js create mode 100644 test/wpt/tests/FileAPI/support/send-file-formdata-helper.js create mode 100644 test/wpt/tests/FileAPI/support/upload.txt create mode 100644 test/wpt/tests/FileAPI/support/url-origin.html create mode 100644 test/wpt/tests/FileAPI/unicode.html create mode 100644 test/wpt/tests/FileAPI/url/cross-global-revoke.sub.html create mode 100644 test/wpt/tests/FileAPI/url/multi-global-origin-serialization.sub.html create mode 100644 test/wpt/tests/FileAPI/url/resources/create-helper.html create mode 100644 test/wpt/tests/FileAPI/url/resources/create-helper.js create mode 100644 test/wpt/tests/FileAPI/url/resources/fetch-tests.js create mode 100644 test/wpt/tests/FileAPI/url/resources/revoke-helper.html create mode 100644 test/wpt/tests/FileAPI/url/resources/revoke-helper.js create mode 100644 test/wpt/tests/FileAPI/url/sandboxed-iframe.html create mode 100644 test/wpt/tests/FileAPI/url/unicode-origin.sub.html create mode 100644 test/wpt/tests/FileAPI/url/url-charset.window.js create mode 100644 test/wpt/tests/FileAPI/url/url-format.any.js create mode 100644 test/wpt/tests/FileAPI/url/url-in-tags-revoke.window.js create mode 100644 test/wpt/tests/FileAPI/url/url-in-tags.window.js create mode 100644 test/wpt/tests/FileAPI/url/url-lifetime.html create mode 100644 test/wpt/tests/FileAPI/url/url-reload.window.js create mode 100644 test/wpt/tests/FileAPI/url/url-with-fetch.any.js create mode 100644 test/wpt/tests/FileAPI/url/url-with-xhr.any.js create mode 100644 test/wpt/tests/FileAPI/url/url_createobjecturl_file-manual.html create mode 100644 test/wpt/tests/FileAPI/url/url_createobjecturl_file_img-manual.html create mode 100644 test/wpt/tests/FileAPI/url/url_xmlhttprequest_img-ref.html create mode 100644 test/wpt/tests/FileAPI/url/url_xmlhttprequest_img.html create mode 100644 test/wpt/tests/LICENSE.md create mode 100644 test/wpt/tests/README.md create mode 100644 test/wpt/tests/common/CustomCorsResponse.py create mode 100644 test/wpt/tests/common/META.yml create mode 100644 test/wpt/tests/common/PrefixedLocalStorage.js create mode 100644 test/wpt/tests/common/PrefixedLocalStorage.js.headers create mode 100644 test/wpt/tests/common/PrefixedPostMessage.js create mode 100644 test/wpt/tests/common/PrefixedPostMessage.js.headers create mode 100644 test/wpt/tests/common/README.md create mode 100644 test/wpt/tests/common/__init__.py create mode 100644 test/wpt/tests/common/arrays.js create mode 100644 test/wpt/tests/common/blank-with-cors.html create mode 100644 test/wpt/tests/common/blank-with-cors.html.headers create mode 100644 test/wpt/tests/common/blank.html create mode 100644 test/wpt/tests/common/custom-cors-response.js create mode 100644 test/wpt/tests/common/dispatcher/README.md create mode 100644 test/wpt/tests/common/dispatcher/dispatcher.js create mode 100644 test/wpt/tests/common/dispatcher/dispatcher.py create mode 100644 test/wpt/tests/common/dispatcher/executor-service-worker.js create mode 100644 test/wpt/tests/common/dispatcher/executor-worker.js create mode 100644 test/wpt/tests/common/dispatcher/executor.html create mode 100644 test/wpt/tests/common/dispatcher/remote-executor.html create mode 100644 test/wpt/tests/common/domain-setter.sub.html create mode 100644 test/wpt/tests/common/dummy.xhtml create mode 100644 test/wpt/tests/common/dummy.xml create mode 100644 test/wpt/tests/common/echo.py create mode 100644 test/wpt/tests/common/gc.js create mode 100644 test/wpt/tests/common/get-host-info.sub.js create mode 100644 test/wpt/tests/common/get-host-info.sub.js.headers create mode 100644 test/wpt/tests/common/media.js create mode 100644 test/wpt/tests/common/media.js.headers create mode 100644 test/wpt/tests/common/object-association.js create mode 100644 test/wpt/tests/common/object-association.js.headers create mode 100644 test/wpt/tests/common/performance-timeline-utils.js create mode 100644 test/wpt/tests/common/performance-timeline-utils.js.headers create mode 100644 test/wpt/tests/common/proxy-all.sub.pac create mode 100644 test/wpt/tests/common/redirect-opt-in.py create mode 100644 test/wpt/tests/common/redirect.py create mode 100644 test/wpt/tests/common/refresh.py create mode 100644 test/wpt/tests/common/reftest-wait.js create mode 100644 test/wpt/tests/common/reftest-wait.js.headers create mode 100644 test/wpt/tests/common/rendering-utils.js create mode 100644 test/wpt/tests/common/sab.js create mode 100644 test/wpt/tests/common/security-features/README.md create mode 100644 test/wpt/tests/common/security-features/__init__.py create mode 100644 test/wpt/tests/common/security-features/resources/common.sub.js create mode 100644 test/wpt/tests/common/security-features/resources/common.sub.js.headers create mode 100644 test/wpt/tests/common/security-features/scope/__init__.py create mode 100644 test/wpt/tests/common/security-features/scope/document.py create mode 100644 test/wpt/tests/common/security-features/scope/template/document.html.template create mode 100644 test/wpt/tests/common/security-features/scope/template/worker.js.template create mode 100644 test/wpt/tests/common/security-features/scope/util.py create mode 100644 test/wpt/tests/common/security-features/scope/worker.py create mode 100644 test/wpt/tests/common/security-features/subresource/__init__.py create mode 100644 test/wpt/tests/common/security-features/subresource/audio.py create mode 100644 test/wpt/tests/common/security-features/subresource/document.py create mode 100644 test/wpt/tests/common/security-features/subresource/empty.py create mode 100644 test/wpt/tests/common/security-features/subresource/font.py create mode 100644 test/wpt/tests/common/security-features/subresource/image.py create mode 100644 test/wpt/tests/common/security-features/subresource/referrer.py create mode 100644 test/wpt/tests/common/security-features/subresource/script.py create mode 100644 test/wpt/tests/common/security-features/subresource/shared-worker.py create mode 100644 test/wpt/tests/common/security-features/subresource/static-import.py create mode 100644 test/wpt/tests/common/security-features/subresource/stylesheet.py create mode 100644 test/wpt/tests/common/security-features/subresource/subresource.py create mode 100644 test/wpt/tests/common/security-features/subresource/svg.py create mode 100644 test/wpt/tests/common/security-features/subresource/template/document.html.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/font.css.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/image.css.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/script.js.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/shared-worker.js.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/static-import.js.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/svg.css.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/svg.embedded.template create mode 100644 test/wpt/tests/common/security-features/subresource/template/worker.js.template create mode 100644 test/wpt/tests/common/security-features/subresource/video.py create mode 100644 test/wpt/tests/common/security-features/subresource/worker.py create mode 100644 test/wpt/tests/common/security-features/subresource/xhr.py create mode 100644 test/wpt/tests/common/security-features/tools/format_spec_src_json.py create mode 100644 test/wpt/tests/common/security-features/tools/generate.py create mode 100644 test/wpt/tests/common/security-features/tools/spec.src.json create mode 100644 test/wpt/tests/common/security-features/tools/spec_validator.py create mode 100644 test/wpt/tests/common/security-features/tools/template/disclaimer.template create mode 100644 test/wpt/tests/common/security-features/tools/template/spec_json.js.template create mode 100644 test/wpt/tests/common/security-features/tools/template/test.debug.html.template create mode 100644 test/wpt/tests/common/security-features/tools/template/test.release.html.template create mode 100644 test/wpt/tests/common/security-features/tools/util.py create mode 100644 test/wpt/tests/common/security-features/types.md create mode 100644 test/wpt/tests/common/slow-redirect.py create mode 100644 test/wpt/tests/common/slow.py create mode 100644 test/wpt/tests/common/square.png create mode 100644 test/wpt/tests/common/stringifiers.js create mode 100644 test/wpt/tests/common/stringifiers.js.headers create mode 100644 test/wpt/tests/common/subset-tests-by-key.js create mode 100644 test/wpt/tests/common/subset-tests.js create mode 100644 test/wpt/tests/common/test-setting-immutable-prototype.js create mode 100644 test/wpt/tests/common/test-setting-immutable-prototype.js.headers create mode 100644 test/wpt/tests/common/text-plain.txt create mode 100644 test/wpt/tests/common/third_party/reftest-analyzer.xhtml create mode 100644 test/wpt/tests/common/utils.js create mode 100644 test/wpt/tests/common/utils.js.headers create mode 100644 test/wpt/tests/common/window-name-setter.html create mode 100644 test/wpt/tests/common/worklet-reftest.js create mode 100644 test/wpt/tests/common/worklet-reftest.js.headers create mode 100644 test/wpt/tests/fetch/META.yml create mode 100644 test/wpt/tests/fetch/README.md create mode 100644 test/wpt/tests/fetch/api/abort/cache.https.any.js create mode 100644 test/wpt/tests/fetch/api/abort/destroyed-context.html create mode 100644 test/wpt/tests/fetch/api/abort/general.any.js create mode 100644 test/wpt/tests/fetch/api/abort/keepalive.html create mode 100644 test/wpt/tests/fetch/api/abort/request.any.js create mode 100644 test/wpt/tests/fetch/api/abort/serviceworker-intercepted.https.html create mode 100644 test/wpt/tests/fetch/api/basic/accept-header.any.js create mode 100644 test/wpt/tests/fetch/api/basic/block-mime-as-script.html create mode 100644 test/wpt/tests/fetch/api/basic/conditional-get.any.js create mode 100644 test/wpt/tests/fetch/api/basic/error-after-response.any.js create mode 100644 test/wpt/tests/fetch/api/basic/header-value-combining.any.js create mode 100644 test/wpt/tests/fetch/api/basic/header-value-null-byte.any.js create mode 100644 test/wpt/tests/fetch/api/basic/historical.any.js create mode 100644 test/wpt/tests/fetch/api/basic/http-response-code.any.js create mode 100644 test/wpt/tests/fetch/api/basic/integrity.sub.any.js create mode 100644 test/wpt/tests/fetch/api/basic/keepalive.any.js create mode 100644 test/wpt/tests/fetch/api/basic/mediasource.window.js create mode 100644 test/wpt/tests/fetch/api/basic/mode-no-cors.sub.any.js create mode 100644 test/wpt/tests/fetch/api/basic/mode-same-origin.any.js create mode 100644 test/wpt/tests/fetch/api/basic/referrer.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-forbidden-headers.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-head.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-headers-case.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-headers-nonascii.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-headers.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-referrer-redirected-worker.html create mode 100644 test/wpt/tests/fetch/api/basic/request-referrer.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-upload.any.js create mode 100644 test/wpt/tests/fetch/api/basic/request-upload.h2.any.js create mode 100644 test/wpt/tests/fetch/api/basic/response-null-body.any.js create mode 100644 test/wpt/tests/fetch/api/basic/response-url.sub.any.js create mode 100644 test/wpt/tests/fetch/api/basic/scheme-about.any.js create mode 100644 test/wpt/tests/fetch/api/basic/scheme-blob.sub.any.js create mode 100644 test/wpt/tests/fetch/api/basic/scheme-data.any.js create mode 100644 test/wpt/tests/fetch/api/basic/scheme-others.sub.any.js create mode 100644 test/wpt/tests/fetch/api/basic/status.h2.any.js create mode 100644 test/wpt/tests/fetch/api/basic/stream-response.any.js create mode 100644 test/wpt/tests/fetch/api/basic/stream-safe-creation.any.js create mode 100644 test/wpt/tests/fetch/api/basic/text-utf8.any.js create mode 100644 test/wpt/tests/fetch/api/body/cloned-any.js create mode 100644 test/wpt/tests/fetch/api/body/formdata.any.js create mode 100644 test/wpt/tests/fetch/api/body/mime-type.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-basic.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-cookies-redirect.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-cookies.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-expose-star.sub.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-filtering.sub.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-keepalive.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-multiple-origins.sub.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-no-preflight.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-origin.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-cache.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-redirect.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-referrer.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-response-validation.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-star.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight-status.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-preflight.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-redirect-credentials.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-redirect-preflight.any.js create mode 100644 test/wpt/tests/fetch/api/cors/cors-redirect.any.js create mode 100644 test/wpt/tests/fetch/api/cors/data-url-iframe.html create mode 100644 test/wpt/tests/fetch/api/cors/data-url-shared-worker.html create mode 100644 test/wpt/tests/fetch/api/cors/data-url-worker.html create mode 100644 test/wpt/tests/fetch/api/cors/resources/corspreflight.js create mode 100644 test/wpt/tests/fetch/api/cors/resources/not-cors-safelisted.json create mode 100644 test/wpt/tests/fetch/api/cors/sandboxed-iframe.html create mode 100644 test/wpt/tests/fetch/api/crashtests/body-window-destroy.html create mode 100644 test/wpt/tests/fetch/api/crashtests/request.html create mode 100644 test/wpt/tests/fetch/api/credentials/authentication-basic.any.js create mode 100644 test/wpt/tests/fetch/api/credentials/authentication-redirection.any.js create mode 100644 test/wpt/tests/fetch/api/credentials/cookies.any.js create mode 100644 test/wpt/tests/fetch/api/headers/header-setcookie.any.js create mode 100644 test/wpt/tests/fetch/api/headers/header-values-normalize.any.js create mode 100644 test/wpt/tests/fetch/api/headers/header-values.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-basic.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-casing.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-combine.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-errors.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-no-cors.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-normalize.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-record.any.js create mode 100644 test/wpt/tests/fetch/api/headers/headers-structure.any.js create mode 100644 test/wpt/tests/fetch/api/idlharness.any.js create mode 100644 test/wpt/tests/fetch/api/policies/csp-blocked-worker.html create mode 100644 test/wpt/tests/fetch/api/policies/csp-blocked.html create mode 100644 test/wpt/tests/fetch/api/policies/csp-blocked.html.headers create mode 100644 test/wpt/tests/fetch/api/policies/csp-blocked.js create mode 100644 test/wpt/tests/fetch/api/policies/csp-blocked.js.headers create mode 100644 test/wpt/tests/fetch/api/policies/nested-policy.js create mode 100644 test/wpt/tests/fetch/api/policies/nested-policy.js.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer-service-worker.https.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer-worker.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer.html.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer.js create mode 100644 test/wpt/tests/fetch/api/policies/referrer-no-referrer.js.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-service-worker.https.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-worker.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin-worker.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin.html.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin.js create mode 100644 test/wpt/tests/fetch/api/policies/referrer-origin.js.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url-service-worker.https.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url-worker.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html.headers create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js create mode 100644 test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js.headers create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-back-to-original-origin.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-count.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-empty-location.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-keepalive.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-location-escape.tentative.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-location.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-method.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-mode.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-origin.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-referrer-override.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-referrer.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-schemes.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-to-dataurl.any.js create mode 100644 test/wpt/tests/fetch/api/redirect/redirect-upload.h2.any.js create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination-frame.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination-iframe.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination-no-load-event.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination-prefetch.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination-worker.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/fetch-destination.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy.es create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy.es.headers create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy.html create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy.png create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy.ttf create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy_audio.mp3 create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy_audio.oga create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy_video.mp4 create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy_video.ogv create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/dummy_video.webm create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/empty.https.html create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-frame.js create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker.js create mode 100644 test/wpt/tests/fetch/api/request/destination/resources/importer.js create mode 100644 test/wpt/tests/fetch/api/request/forbidden-method.any.js create mode 100644 test/wpt/tests/fetch/api/request/multi-globals/construct-in-detached-frame.window.js create mode 100644 test/wpt/tests/fetch/api/request/multi-globals/current/current.html create mode 100644 test/wpt/tests/fetch/api/request/multi-globals/incumbent/incumbent.html create mode 100644 test/wpt/tests/fetch/api/request/multi-globals/url-parsing.html create mode 100644 test/wpt/tests/fetch/api/request/request-bad-port.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-default-conditional.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-default.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-force-cache.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-no-cache.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-no-store.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-only-if-cached.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache-reload.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-cache.js create mode 100644 test/wpt/tests/fetch/api/request/request-clone.sub.html create mode 100644 test/wpt/tests/fetch/api/request/request-consume-empty.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-consume.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-disturbed.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-error.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-error.js create mode 100644 test/wpt/tests/fetch/api/request/request-headers.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-init-001.sub.html create mode 100644 test/wpt/tests/fetch/api/request/request-init-002.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-init-003.sub.html create mode 100644 test/wpt/tests/fetch/api/request/request-init-contenttype.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-init-priority.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-init-stream.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-keepalive-quota.html create mode 100644 test/wpt/tests/fetch/api/request/request-keepalive.any.js create mode 100644 test/wpt/tests/fetch/api/request/request-reset-attributes.https.html create mode 100644 test/wpt/tests/fetch/api/request/request-structure.any.js create mode 100644 test/wpt/tests/fetch/api/request/resources/cache.py create mode 100644 test/wpt/tests/fetch/api/request/resources/hello.txt create mode 100644 test/wpt/tests/fetch/api/request/resources/request-reset-attributes-worker.js create mode 100644 test/wpt/tests/fetch/api/request/url-encoding.html create mode 100644 test/wpt/tests/fetch/api/resources/authentication.py create mode 100644 test/wpt/tests/fetch/api/resources/bad-chunk-encoding.py create mode 100644 test/wpt/tests/fetch/api/resources/basic.html create mode 100644 test/wpt/tests/fetch/api/resources/cache.py create mode 100644 test/wpt/tests/fetch/api/resources/clean-stash.py create mode 100644 test/wpt/tests/fetch/api/resources/cors-top.txt create mode 100644 test/wpt/tests/fetch/api/resources/cors-top.txt.headers create mode 100644 test/wpt/tests/fetch/api/resources/data.json create mode 100644 test/wpt/tests/fetch/api/resources/dump-authorization-header.py create mode 100644 test/wpt/tests/fetch/api/resources/echo-content.h2.py create mode 100644 test/wpt/tests/fetch/api/resources/echo-content.py create mode 100644 test/wpt/tests/fetch/api/resources/empty.txt create mode 100644 test/wpt/tests/fetch/api/resources/infinite-slow-response.py create mode 100644 test/wpt/tests/fetch/api/resources/inspect-headers.py create mode 100644 test/wpt/tests/fetch/api/resources/keepalive-helper.js create mode 100644 test/wpt/tests/fetch/api/resources/keepalive-iframe.html create mode 100644 test/wpt/tests/fetch/api/resources/keepalive-redirect-iframe.html create mode 100644 test/wpt/tests/fetch/api/resources/keepalive-redirect-window.html create mode 100644 test/wpt/tests/fetch/api/resources/method.py create mode 100644 test/wpt/tests/fetch/api/resources/preflight.py create mode 100644 test/wpt/tests/fetch/api/resources/redirect-empty-location.py create mode 100644 test/wpt/tests/fetch/api/resources/redirect.h2.py create mode 100644 test/wpt/tests/fetch/api/resources/redirect.py create mode 100644 test/wpt/tests/fetch/api/resources/sandboxed-iframe.html create mode 100644 test/wpt/tests/fetch/api/resources/script-with-header.py create mode 100644 test/wpt/tests/fetch/api/resources/stash-put.py create mode 100644 test/wpt/tests/fetch/api/resources/stash-take.py create mode 100644 test/wpt/tests/fetch/api/resources/status.py create mode 100644 test/wpt/tests/fetch/api/resources/sw-intercept-abort.js create mode 100644 test/wpt/tests/fetch/api/resources/sw-intercept.js create mode 100644 test/wpt/tests/fetch/api/resources/top.txt create mode 100644 test/wpt/tests/fetch/api/resources/trickle.py create mode 100644 test/wpt/tests/fetch/api/resources/utils.js create mode 100644 test/wpt/tests/fetch/api/response/json.any.js create mode 100644 test/wpt/tests/fetch/api/response/many-empty-chunks-crash.html create mode 100644 test/wpt/tests/fetch/api/response/multi-globals/current/current.html create mode 100644 test/wpt/tests/fetch/api/response/multi-globals/incumbent/incumbent.html create mode 100644 test/wpt/tests/fetch/api/response/multi-globals/relevant/relevant.html create mode 100644 test/wpt/tests/fetch/api/response/multi-globals/url-parsing.html create mode 100644 test/wpt/tests/fetch/api/response/response-body-read-task-handling.html create mode 100644 test/wpt/tests/fetch/api/response/response-cancel-stream.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-clone-iframe.window.js create mode 100644 test/wpt/tests/fetch/api/response/response-clone.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-consume-empty.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-consume-stream.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-consume.html create mode 100644 test/wpt/tests/fetch/api/response/response-error-from-stream.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-error.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-from-stream.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-init-001.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-init-002.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-init-contenttype.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-static-error.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-static-json.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-static-redirect.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-bad-chunk.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-1.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-2.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-3.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-4.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-5.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-6.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-by-pipe.any.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-disturbed-util.js create mode 100644 test/wpt/tests/fetch/api/response/response-stream-with-broken-then.any.js create mode 100644 test/wpt/tests/fetch/connection-pool/network-partition-key.html create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-about-blank-checker.html create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-checker.html create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-iframe-checker.html create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-key.js create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-key.py create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-worker-checker.html create mode 100644 test/wpt/tests/fetch/connection-pool/resources/network-partition-worker.js create mode 100644 test/wpt/tests/fetch/content-encoding/bad-gzip-body.any.js create mode 100644 test/wpt/tests/fetch/content-encoding/gzip-body.any.js create mode 100644 test/wpt/tests/fetch/content-encoding/resources/bad-gzip-body.py create mode 100644 test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz create mode 100644 test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz.headers create mode 100644 test/wpt/tests/fetch/content-encoding/resources/foo.text.gz create mode 100644 test/wpt/tests/fetch/content-encoding/resources/foo.text.gz.headers create mode 100644 test/wpt/tests/fetch/content-length/api-and-duplicate-headers.any.js create mode 100644 test/wpt/tests/fetch/content-length/content-length.html create mode 100644 test/wpt/tests/fetch/content-length/content-length.html.headers create mode 100644 test/wpt/tests/fetch/content-length/parsing.window.js create mode 100644 test/wpt/tests/fetch/content-length/resources/content-length.py create mode 100644 test/wpt/tests/fetch/content-length/resources/content-lengths.json create mode 100644 test/wpt/tests/fetch/content-length/resources/identical-duplicates.asis create mode 100644 test/wpt/tests/fetch/content-length/too-long.window.js create mode 100644 test/wpt/tests/fetch/content-type/README.md create mode 100644 test/wpt/tests/fetch/content-type/multipart-malformed.any.js create mode 100644 test/wpt/tests/fetch/content-type/multipart.window.js create mode 100644 test/wpt/tests/fetch/content-type/resources/content-type.py create mode 100644 test/wpt/tests/fetch/content-type/resources/content-types.json create mode 100644 test/wpt/tests/fetch/content-type/resources/script-content-types.json create mode 100644 test/wpt/tests/fetch/content-type/response.window.js create mode 100644 test/wpt/tests/fetch/content-type/script.window.js create mode 100644 test/wpt/tests/fetch/corb/README.md create mode 100644 test/wpt/tests/fetch/corb/img-html-correctly-labeled.sub-ref.html create mode 100644 test/wpt/tests/fetch/corb/img-html-correctly-labeled.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-mime-types-coverage.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html create mode 100644 test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub-ref.html create mode 100644 test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-invalid.sub-ref.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-labeled-as-dash.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-labeled-as-svg-xml.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg-xml-decl.sub.html create mode 100644 test/wpt/tests/fetch/corb/img-svg.sub-ref.html create mode 100644 test/wpt/tests/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css create mode 100644 test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers create mode 100644 test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css create mode 100644 test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css.headers create mode 100644 test/wpt/tests/fetch/corb/resources/css-with-json-parser-breaker.css create mode 100644 test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png create mode 100644 test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png.headers create mode 100644 test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html create mode 100644 test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html.headers create mode 100644 test/wpt/tests/fetch/corb/resources/html-js-polyglot.js create mode 100644 test/wpt/tests/fetch/corb/resources/html-js-polyglot.js.headers create mode 100644 test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js create mode 100644 test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js.headers create mode 100644 test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js create mode 100644 test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers create mode 100644 test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js create mode 100644 test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js.headers create mode 100644 test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png create mode 100644 test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png.headers create mode 100644 test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png create mode 100644 test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers create mode 100644 test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png create mode 100644 test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png.headers create mode 100644 test/wpt/tests/fetch/corb/resources/response_block_probe.js create mode 100644 test/wpt/tests/fetch/corb/resources/response_block_probe.js.headers create mode 100644 test/wpt/tests/fetch/corb/resources/sniffable-resource.py create mode 100644 test/wpt/tests/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html create mode 100644 test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers create mode 100644 test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers create mode 100644 test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg.headers create mode 100644 test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers create mode 100644 test/wpt/tests/fetch/corb/resources/svg-xml-decl.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg.svg create mode 100644 test/wpt/tests/fetch/corb/resources/svg.svg.headers create mode 100644 test/wpt/tests/fetch/corb/response_block.tentative.https.html create mode 100644 test/wpt/tests/fetch/corb/script-html-correctly-labeled.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-html-js-polyglot.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-html-via-cross-origin-blob-url.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-js-mislabeled-as-html.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html create mode 100644 test/wpt/tests/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html create mode 100644 test/wpt/tests/fetch/corb/style-css-mislabeled-as-html.sub.html create mode 100644 test/wpt/tests/fetch/corb/style-css-with-json-parser-breaker.sub.html create mode 100644 test/wpt/tests/fetch/corb/style-html-correctly-labeled.sub.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/fetch-in-iframe.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/fetch.any.js create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/fetch.https.any.js create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/iframe-loads.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/image-loads.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/green.png create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/hello.py create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/iframe.py create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/iframeFetch.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/image.py create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/redirect.py create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/resources/script.py create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.any.js create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/script-loads.html create mode 100644 test/wpt/tests/fetch/cross-origin-resource-policy/syntax.any.js create mode 100644 test/wpt/tests/fetch/data-urls/README.md create mode 100644 test/wpt/tests/fetch/data-urls/base64.any.js create mode 100644 test/wpt/tests/fetch/data-urls/navigate.window.js create mode 100644 test/wpt/tests/fetch/data-urls/processing.any.js create mode 100644 test/wpt/tests/fetch/data-urls/resources/base64.json create mode 100644 test/wpt/tests/fetch/data-urls/resources/data-urls.json create mode 100644 test/wpt/tests/fetch/fetch-later/META.yml create mode 100644 test/wpt/tests/fetch/fetch-later/README.md create mode 100644 test/wpt/tests/fetch/fetch-later/basic.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/fetch-later/non-secure.window.js create mode 100644 test/wpt/tests/fetch/fetch-later/sendondiscard.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/h1-parsing/README.md create mode 100644 test/wpt/tests/fetch/h1-parsing/lone-cr.window.js create mode 100644 test/wpt/tests/fetch/h1-parsing/resources-with-0x00-in-header.window.js create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/README.md create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/blue-with-0x00-in-a-header.asis create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/document-with-0x00-in-header.py create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/message.py create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/script-with-0x00-in-header.py create mode 100644 test/wpt/tests/fetch/h1-parsing/resources/status-code.py create mode 100644 test/wpt/tests/fetch/h1-parsing/status-code.window.js create mode 100644 test/wpt/tests/fetch/http-cache/304-update.any.js create mode 100644 test/wpt/tests/fetch/http-cache/README.md create mode 100644 test/wpt/tests/fetch/http-cache/basic-auth-cache-test-ref.html create mode 100644 test/wpt/tests/fetch/http-cache/basic-auth-cache-test.html create mode 100644 test/wpt/tests/fetch/http-cache/cache-mode.any.js create mode 100644 test/wpt/tests/fetch/http-cache/cc-request.any.js create mode 100644 test/wpt/tests/fetch/http-cache/credentials.tentative.any.js create mode 100644 test/wpt/tests/fetch/http-cache/freshness.any.js create mode 100644 test/wpt/tests/fetch/http-cache/heuristic.any.js create mode 100644 test/wpt/tests/fetch/http-cache/http-cache.js create mode 100644 test/wpt/tests/fetch/http-cache/invalidate.any.js create mode 100644 test/wpt/tests/fetch/http-cache/partial.any.js create mode 100644 test/wpt/tests/fetch/http-cache/post-patch.any.js create mode 100644 test/wpt/tests/fetch/http-cache/resources/http-cache.py create mode 100644 test/wpt/tests/fetch/http-cache/resources/securedimage.py create mode 100644 test/wpt/tests/fetch/http-cache/resources/split-cache-popup-with-iframe.html create mode 100644 test/wpt/tests/fetch/http-cache/resources/split-cache-popup.html create mode 100644 test/wpt/tests/fetch/http-cache/split-cache.html create mode 100644 test/wpt/tests/fetch/http-cache/status.any.js create mode 100644 test/wpt/tests/fetch/http-cache/vary.any.js create mode 100644 test/wpt/tests/fetch/images/canvas-remote-read-remote-image-redirect.html create mode 100644 test/wpt/tests/fetch/metadata/META.yml create mode 100644 test/wpt/tests/fetch/metadata/README.md create mode 100644 test/wpt/tests/fetch/metadata/audio-worklet.https.html create mode 100644 test/wpt/tests/fetch/metadata/embed.https.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/fetch-preflight.https.sub.any.js create mode 100644 test/wpt/tests/fetch/metadata/fetch.https.sub.any.js create mode 100644 test/wpt/tests/fetch/metadata/generated/appcache-manifest.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/audioworklet.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/css-font-face.https.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/generated/css-font-face.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/generated/css-images.https.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/generated/css-images.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-a.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-a.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-area.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-area.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-audio.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-audio.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-embed.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-embed.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-frame.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-frame.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-iframe.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-iframe.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-img-environment-change.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-img-environment-change.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-img.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-img.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-input-image.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-input-image.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-link-icon.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-link-icon.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-link-prefetch.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-meta-refresh.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-picture.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-picture.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-script.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-script.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-video-poster.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-video-poster.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-video.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/element-video.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/fetch.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/fetch.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/form-submission.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/form-submission.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/header-link.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/header-link.https.sub.tentative.html create mode 100644 test/wpt/tests/fetch/metadata/generated/header-link.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/header-refresh.https.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/header-refresh.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/script-module-import-static.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/script-module-import-static.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/serviceworker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/svg-image.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/svg-image.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/window-history.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/window-history.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/window-location.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/window-location.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.sub.html create mode 100644 test/wpt/tests/fetch/metadata/navigation.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/object.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/paint-worklet.https.html create mode 100644 test/wpt/tests/fetch/metadata/portal.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/preload.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html create mode 100644 test/wpt/tests/fetch/metadata/redirect/redirect-http-upgrade.sub.html create mode 100644 test/wpt/tests/fetch/metadata/redirect/redirect-https-downgrade.sub.html create mode 100644 test/wpt/tests/fetch/metadata/report.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/report.https.sub.html.sub.headers create mode 100644 test/wpt/tests/fetch/metadata/resources/appcache-iframe.sub.html create mode 100644 test/wpt/tests/fetch/metadata/resources/dedicatedWorker.js create mode 100644 test/wpt/tests/fetch/metadata/resources/echo-as-json.py create mode 100644 test/wpt/tests/fetch/metadata/resources/echo-as-script.py create mode 100644 test/wpt/tests/fetch/metadata/resources/es-module.sub.js create mode 100644 test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js create mode 100644 test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js create mode 100644 test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker-frame.html create mode 100644 test/wpt/tests/fetch/metadata/resources/header-link.py create mode 100644 test/wpt/tests/fetch/metadata/resources/helper.js create mode 100644 test/wpt/tests/fetch/metadata/resources/helper.sub.js create mode 100644 test/wpt/tests/fetch/metadata/resources/message-opener.html create mode 100644 test/wpt/tests/fetch/metadata/resources/post-to-owner.py create mode 100644 test/wpt/tests/fetch/metadata/resources/record-header.py create mode 100644 test/wpt/tests/fetch/metadata/resources/record-headers.py create mode 100644 test/wpt/tests/fetch/metadata/resources/redirectTestHelper.sub.js create mode 100644 test/wpt/tests/fetch/metadata/resources/serviceworker-accessors-frame.html create mode 100644 test/wpt/tests/fetch/metadata/resources/serviceworker-accessors.sw.js create mode 100644 test/wpt/tests/fetch/metadata/resources/sharedWorker.js create mode 100644 test/wpt/tests/fetch/metadata/resources/unload-with-beacon.html create mode 100644 test/wpt/tests/fetch/metadata/resources/xslt-test.sub.xml create mode 100644 test/wpt/tests/fetch/metadata/serviceworker-accessors.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/sharedworker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/style.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/README.md create mode 100644 test/wpt/tests/fetch/metadata/tools/fetch-metadata.conf.yml create mode 100644 test/wpt/tests/fetch/metadata/tools/generate.py create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/appcache-manifest.sub.https.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/audioworklet.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/css-font-face.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/css-images.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-a.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-area.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-audio.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-embed.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-frame.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-iframe.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-img-environment-change.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-img.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-input-image.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-link-icon.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-picture.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-script.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-video-poster.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/element-video.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/fetch.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/form-submission.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/header-link.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/header-refresh.optional.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/script-module-import-static.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/serviceworker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/svg-image.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/window-history.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/window-location.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html create mode 100644 test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html create mode 100644 test/wpt/tests/fetch/metadata/track.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/trailing-dot.https.sub.any.js create mode 100644 test/wpt/tests/fetch/metadata/unload.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/window-open.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/worker.https.sub.html create mode 100644 test/wpt/tests/fetch/metadata/xslt.https.sub.html create mode 100644 test/wpt/tests/fetch/nosniff/image.html create mode 100644 test/wpt/tests/fetch/nosniff/importscripts.html create mode 100644 test/wpt/tests/fetch/nosniff/importscripts.js create mode 100644 test/wpt/tests/fetch/nosniff/parsing-nosniff.window.js create mode 100644 test/wpt/tests/fetch/nosniff/resources/css.py create mode 100644 test/wpt/tests/fetch/nosniff/resources/image.py create mode 100644 test/wpt/tests/fetch/nosniff/resources/js.py create mode 100644 test/wpt/tests/fetch/nosniff/resources/nosniff.py create mode 100644 test/wpt/tests/fetch/nosniff/resources/worker.py create mode 100644 test/wpt/tests/fetch/nosniff/resources/x-content-type-options.json create mode 100644 test/wpt/tests/fetch/nosniff/script.html create mode 100644 test/wpt/tests/fetch/nosniff/stylesheet.html create mode 100644 test/wpt/tests/fetch/nosniff/worker.html create mode 100644 test/wpt/tests/fetch/orb/resources/data.json create mode 100644 test/wpt/tests/fetch/orb/resources/data_non_ascii.json create mode 100644 test/wpt/tests/fetch/orb/resources/empty.json create mode 100644 test/wpt/tests/fetch/orb/resources/font.ttf create mode 100644 test/wpt/tests/fetch/orb/resources/image.png create mode 100644 test/wpt/tests/fetch/orb/resources/js-unlabeled-utf16-without-bom.json create mode 100644 test/wpt/tests/fetch/orb/resources/js-unlabeled.js create mode 100644 test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png create mode 100644 test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png.headers create mode 100644 test/wpt/tests/fetch/orb/resources/png-unlabeled.png create mode 100644 test/wpt/tests/fetch/orb/resources/script-asm-js-invalid.js create mode 100644 test/wpt/tests/fetch/orb/resources/script-asm-js-valid.js create mode 100644 test/wpt/tests/fetch/orb/resources/script-iso-8559-1.js create mode 100644 test/wpt/tests/fetch/orb/resources/script-utf16-bom.js create mode 100644 test/wpt/tests/fetch/orb/resources/script-utf16-without-bom.js create mode 100644 test/wpt/tests/fetch/orb/resources/script.js create mode 100644 test/wpt/tests/fetch/orb/resources/sound.mp3 create mode 100644 test/wpt/tests/fetch/orb/resources/text.txt create mode 100644 test/wpt/tests/fetch/orb/resources/utils.js create mode 100644 test/wpt/tests/fetch/orb/tentative/compressed-image-sniffing.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/content-range.sub.any.js create mode 100644 test/wpt/tests/fetch/orb/tentative/img-mime-types-coverage.tentative.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub-ref.html create mode 100644 test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub-ref.html create mode 100644 test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/known-mime-type.sub.any.js create mode 100644 test/wpt/tests/fetch/orb/tentative/nosniff.sub.any.js create mode 100644 test/wpt/tests/fetch/orb/tentative/script-js-unlabeled-gziped.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/script-unlabeled.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/script-utf16-without-bom-hint-charset.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/status.sub.any.js create mode 100644 test/wpt/tests/fetch/orb/tentative/status.sub.html create mode 100644 test/wpt/tests/fetch/orb/tentative/unknown-mime-type.sub.any.js create mode 100644 test/wpt/tests/fetch/origin/assorted.window.js create mode 100644 test/wpt/tests/fetch/origin/resources/redirect-and-stash.py create mode 100644 test/wpt/tests/fetch/origin/resources/referrer-policy.py create mode 100644 test/wpt/tests/fetch/private-network-access/META.yml create mode 100644 test/wpt/tests/fetch/private-network-access/README.md create mode 100644 test/wpt/tests/fetch/private-network-access/fenced-frame-no-preflight-required.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/fenced-frame-subresource-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/fenced-frame.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/fetch-from-treat-as-public.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/fetch.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/iframe.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/iframe.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/mixed-content-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/nested-worker.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/nested-worker.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/preflight-cache.https.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/redirect.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/executor.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html.headers create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access-target.https.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html.headers create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fetcher.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/fetcher.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/iframed.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/iframer.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/preflight.py create mode 100644 test/wpt/tests/fetch/private-network-access/resources/service-worker-bridge.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/service-worker.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/shared-fetcher.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/shared-worker-blob-fetcher.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/shared-worker-fetcher.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/socket-opener.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/support.sub.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/worker-blob-fetcher.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.html create mode 100644 test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.js create mode 100644 test/wpt/tests/fetch/private-network-access/resources/xhr-sender.html create mode 100644 test/wpt/tests/fetch/private-network-access/service-worker-background-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/service-worker-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/service-worker-update.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/service-worker.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/shared-worker.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/websocket.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/websocket.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/worker-blob-fetch.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/worker.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/worker.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/xhr-from-treat-as-public.tentative.https.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/xhr.https.tentative.window.js create mode 100644 test/wpt/tests/fetch/private-network-access/xhr.tentative.window.js create mode 100644 test/wpt/tests/fetch/range/blob.any.js create mode 100644 test/wpt/tests/fetch/range/data.any.js create mode 100644 test/wpt/tests/fetch/range/general.any.js create mode 100644 test/wpt/tests/fetch/range/general.window.js create mode 100644 test/wpt/tests/fetch/range/non-matching-range-response.html create mode 100644 test/wpt/tests/fetch/range/resources/basic.html create mode 100644 test/wpt/tests/fetch/range/resources/long-wav.py create mode 100644 test/wpt/tests/fetch/range/resources/partial-script.py create mode 100644 test/wpt/tests/fetch/range/resources/partial-text.py create mode 100644 test/wpt/tests/fetch/range/resources/range-sw.js create mode 100644 test/wpt/tests/fetch/range/resources/stash-take.py create mode 100644 test/wpt/tests/fetch/range/resources/utils.js create mode 100644 test/wpt/tests/fetch/range/resources/video-with-range.py create mode 100644 test/wpt/tests/fetch/range/sw.https.window.js create mode 100644 test/wpt/tests/fetch/redirect-navigate/302-found-post-handler.py create mode 100644 test/wpt/tests/fetch/redirect-navigate/302-found-post.html create mode 100644 test/wpt/tests/fetch/redirect-navigate/preserve-fragment.html create mode 100644 test/wpt/tests/fetch/redirect-navigate/resources/destination.html create mode 100644 test/wpt/tests/fetch/redirects/data.window.js create mode 100644 test/wpt/tests/fetch/redirects/subresource-fragments.html create mode 100644 test/wpt/tests/fetch/security/1xx-response.any.js create mode 100644 test/wpt/tests/fetch/security/dangling-markup-mitigation-data-url.tentative.sub.html create mode 100644 test/wpt/tests/fetch/security/dangling-markup-mitigation.tentative.html create mode 100644 test/wpt/tests/fetch/security/embedded-credentials.tentative.sub.html create mode 100644 test/wpt/tests/fetch/security/redirect-to-url-with-credentials.https.html create mode 100644 test/wpt/tests/fetch/security/support/embedded-credential-window.sub.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/fetch-sw.https.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/fetch.any.js create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/resources/stale-css.py create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/resources/stale-image.py create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/resources/stale-script.py create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/revalidate-not-blocked-by-csp.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/stale-css.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/stale-image.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/stale-script.html create mode 100644 test/wpt/tests/fetch/stale-while-revalidate/sw-intercept.js create mode 100644 test/wpt/tests/interfaces/ANGLE_instanced_arrays.idl create mode 100644 test/wpt/tests/interfaces/CSP.idl create mode 100644 test/wpt/tests/interfaces/DOM-Parsing.idl create mode 100644 test/wpt/tests/interfaces/EXT_blend_minmax.idl create mode 100644 test/wpt/tests/interfaces/EXT_color_buffer_float.idl create mode 100644 test/wpt/tests/interfaces/EXT_color_buffer_half_float.idl create mode 100644 test/wpt/tests/interfaces/EXT_disjoint_timer_query.idl create mode 100644 test/wpt/tests/interfaces/EXT_disjoint_timer_query_webgl2.idl create mode 100644 test/wpt/tests/interfaces/EXT_float_blend.idl create mode 100644 test/wpt/tests/interfaces/EXT_frag_depth.idl create mode 100644 test/wpt/tests/interfaces/EXT_sRGB.idl create mode 100644 test/wpt/tests/interfaces/EXT_shader_texture_lod.idl create mode 100644 test/wpt/tests/interfaces/EXT_texture_compression_bptc.idl create mode 100644 test/wpt/tests/interfaces/EXT_texture_compression_rgtc.idl create mode 100644 test/wpt/tests/interfaces/EXT_texture_filter_anisotropic.idl create mode 100644 test/wpt/tests/interfaces/EXT_texture_norm16.idl create mode 100644 test/wpt/tests/interfaces/FedCM.idl create mode 100644 test/wpt/tests/interfaces/FileAPI.idl create mode 100644 test/wpt/tests/interfaces/IndexedDB.idl create mode 100644 test/wpt/tests/interfaces/KHR_parallel_shader_compile.idl create mode 100644 test/wpt/tests/interfaces/META.yml create mode 100644 test/wpt/tests/interfaces/OES_draw_buffers_indexed.idl create mode 100644 test/wpt/tests/interfaces/OES_element_index_uint.idl create mode 100644 test/wpt/tests/interfaces/OES_fbo_render_mipmap.idl create mode 100644 test/wpt/tests/interfaces/OES_standard_derivatives.idl create mode 100644 test/wpt/tests/interfaces/OES_texture_float.idl create mode 100644 test/wpt/tests/interfaces/OES_texture_float_linear.idl create mode 100644 test/wpt/tests/interfaces/OES_texture_half_float.idl create mode 100644 test/wpt/tests/interfaces/OES_texture_half_float_linear.idl create mode 100644 test/wpt/tests/interfaces/OES_vertex_array_object.idl create mode 100644 test/wpt/tests/interfaces/OVR_multiview2.idl create mode 100644 test/wpt/tests/interfaces/README.md create mode 100644 test/wpt/tests/interfaces/SVG.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_blend_equation_advanced_coherent.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_clip_cull_distance.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_color_buffer_float.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_astc.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_etc.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_etc1.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_pvrtc.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc_srgb.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_debug_renderer_info.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_debug_shaders.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_depth_texture.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_draw_buffers.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_draw_instanced_base_vertex_base_instance.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_lose_context.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_multi_draw.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_multi_draw_instanced_base_vertex_base_instance.idl create mode 100644 test/wpt/tests/interfaces/WEBGL_provoking_vertex.idl create mode 100644 test/wpt/tests/interfaces/WebCryptoAPI.idl create mode 100644 test/wpt/tests/interfaces/accelerometer.idl create mode 100644 test/wpt/tests/interfaces/ambient-light.idl create mode 100644 test/wpt/tests/interfaces/anchors.idl create mode 100644 test/wpt/tests/interfaces/attribution-reporting-api.idl create mode 100644 test/wpt/tests/interfaces/audio-output.idl create mode 100644 test/wpt/tests/interfaces/autoplay-detection.idl create mode 100644 test/wpt/tests/interfaces/background-fetch.idl create mode 100644 test/wpt/tests/interfaces/background-sync.idl create mode 100644 test/wpt/tests/interfaces/badging.idl create mode 100644 test/wpt/tests/interfaces/battery-status.idl create mode 100644 test/wpt/tests/interfaces/beacon.idl create mode 100644 test/wpt/tests/interfaces/capture-handle-identity.idl create mode 100644 test/wpt/tests/interfaces/captured-mouse-events.tentative.idl create mode 100644 test/wpt/tests/interfaces/clipboard-apis.idl create mode 100644 test/wpt/tests/interfaces/close-watcher.idl create mode 100644 test/wpt/tests/interfaces/compat.idl create mode 100644 test/wpt/tests/interfaces/compression.idl create mode 100644 test/wpt/tests/interfaces/compute-pressure.idl create mode 100644 test/wpt/tests/interfaces/console.idl create mode 100644 test/wpt/tests/interfaces/contact-picker.idl create mode 100644 test/wpt/tests/interfaces/content-index.idl create mode 100644 test/wpt/tests/interfaces/cookie-store.idl create mode 100644 test/wpt/tests/interfaces/credential-management.idl create mode 100644 test/wpt/tests/interfaces/csp-embedded-enforcement.idl create mode 100644 test/wpt/tests/interfaces/csp-next.idl create mode 100644 test/wpt/tests/interfaces/css-anchor-position.idl create mode 100644 test/wpt/tests/interfaces/css-animation-worklet.idl create mode 100644 test/wpt/tests/interfaces/css-animations-2.idl create mode 100644 test/wpt/tests/interfaces/css-animations.idl create mode 100644 test/wpt/tests/interfaces/css-cascade-6.idl create mode 100644 test/wpt/tests/interfaces/css-cascade.idl create mode 100644 test/wpt/tests/interfaces/css-color-5.idl create mode 100644 test/wpt/tests/interfaces/css-conditional.idl create mode 100644 test/wpt/tests/interfaces/css-contain-3.idl create mode 100644 test/wpt/tests/interfaces/css-contain.idl create mode 100644 test/wpt/tests/interfaces/css-counter-styles.idl create mode 100644 test/wpt/tests/interfaces/css-font-loading.idl create mode 100644 test/wpt/tests/interfaces/css-fonts.idl create mode 100644 test/wpt/tests/interfaces/css-highlight-api.idl create mode 100644 test/wpt/tests/interfaces/css-images-4.idl create mode 100644 test/wpt/tests/interfaces/css-layout-api.idl create mode 100644 test/wpt/tests/interfaces/css-masking.idl create mode 100644 test/wpt/tests/interfaces/css-nav.idl create mode 100644 test/wpt/tests/interfaces/css-nesting.idl create mode 100644 test/wpt/tests/interfaces/css-paint-api.idl create mode 100644 test/wpt/tests/interfaces/css-parser-api.idl create mode 100644 test/wpt/tests/interfaces/css-properties-values-api.idl create mode 100644 test/wpt/tests/interfaces/css-pseudo.idl create mode 100644 test/wpt/tests/interfaces/css-regions.idl create mode 100644 test/wpt/tests/interfaces/css-shadow-parts.idl create mode 100644 test/wpt/tests/interfaces/css-toggle.tentative.idl create mode 100644 test/wpt/tests/interfaces/css-transitions-2.idl create mode 100644 test/wpt/tests/interfaces/css-transitions.idl create mode 100644 test/wpt/tests/interfaces/css-typed-om.idl create mode 100644 test/wpt/tests/interfaces/css-view-transitions.idl create mode 100644 test/wpt/tests/interfaces/cssom-view.idl create mode 100644 test/wpt/tests/interfaces/cssom.idl create mode 100644 test/wpt/tests/interfaces/custom-state-pseudo-class.idl create mode 100644 test/wpt/tests/interfaces/datacue.idl create mode 100644 test/wpt/tests/interfaces/deprecation-reporting.idl create mode 100644 test/wpt/tests/interfaces/device-memory.idl create mode 100644 test/wpt/tests/interfaces/device-posture.idl create mode 100644 test/wpt/tests/interfaces/digital-goods.idl create mode 100644 test/wpt/tests/interfaces/document-picture-in-picture.idl create mode 100644 test/wpt/tests/interfaces/dom.idl create mode 100644 test/wpt/tests/interfaces/edit-context.idl create mode 100644 test/wpt/tests/interfaces/element-timing.idl create mode 100644 test/wpt/tests/interfaces/encoding.idl create mode 100644 test/wpt/tests/interfaces/encrypted-media.idl create mode 100644 test/wpt/tests/interfaces/entries-api.idl create mode 100644 test/wpt/tests/interfaces/event-timing.idl create mode 100644 test/wpt/tests/interfaces/eyedropper-api.idl create mode 100644 test/wpt/tests/interfaces/fenced-frame.idl create mode 100644 test/wpt/tests/interfaces/fetch.idl create mode 100644 test/wpt/tests/interfaces/fido.idl create mode 100644 test/wpt/tests/interfaces/file-system-access.idl create mode 100644 test/wpt/tests/interfaces/filter-effects.idl create mode 100644 test/wpt/tests/interfaces/font-metrics-api.idl create mode 100644 test/wpt/tests/interfaces/fs.idl create mode 100644 test/wpt/tests/interfaces/fullscreen.idl create mode 100644 test/wpt/tests/interfaces/gamepad-extensions.idl create mode 100644 test/wpt/tests/interfaces/gamepad.idl create mode 100644 test/wpt/tests/interfaces/generic-sensor.idl create mode 100644 test/wpt/tests/interfaces/geolocation-sensor.idl create mode 100644 test/wpt/tests/interfaces/geolocation.idl create mode 100644 test/wpt/tests/interfaces/geometry.idl create mode 100644 test/wpt/tests/interfaces/get-installed-related-apps.idl create mode 100644 test/wpt/tests/interfaces/gpc-spec.idl create mode 100644 test/wpt/tests/interfaces/gyroscope.idl create mode 100644 test/wpt/tests/interfaces/hr-time.idl create mode 100644 test/wpt/tests/interfaces/html-media-capture.idl create mode 100644 test/wpt/tests/interfaces/html.idl create mode 100644 test/wpt/tests/interfaces/idle-detection.idl create mode 100644 test/wpt/tests/interfaces/image-capture.idl create mode 100644 test/wpt/tests/interfaces/image-resource.idl create mode 100644 test/wpt/tests/interfaces/ink-enhancement.idl create mode 100644 test/wpt/tests/interfaces/input-device-capabilities.idl create mode 100644 test/wpt/tests/interfaces/input-events.idl create mode 100644 test/wpt/tests/interfaces/intersection-observer.idl create mode 100644 test/wpt/tests/interfaces/intervention-reporting.idl create mode 100644 test/wpt/tests/interfaces/is-input-pending.idl create mode 100644 test/wpt/tests/interfaces/js-self-profiling.idl create mode 100644 test/wpt/tests/interfaces/keyboard-lock.idl create mode 100644 test/wpt/tests/interfaces/keyboard-map.idl create mode 100644 test/wpt/tests/interfaces/largest-contentful-paint.idl create mode 100644 test/wpt/tests/interfaces/layout-instability.idl create mode 100644 test/wpt/tests/interfaces/local-font-access.idl create mode 100644 test/wpt/tests/interfaces/longtasks.idl create mode 100644 test/wpt/tests/interfaces/magnetometer.idl create mode 100644 test/wpt/tests/interfaces/manifest-incubations.idl create mode 100644 test/wpt/tests/interfaces/mathml-core.idl create mode 100644 test/wpt/tests/interfaces/media-capabilities.idl create mode 100644 test/wpt/tests/interfaces/media-playback-quality.idl create mode 100644 test/wpt/tests/interfaces/media-source.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-automation.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-fromelement.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-handle-actions.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-region.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-streams.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-transform.idl create mode 100644 test/wpt/tests/interfaces/mediacapture-viewport.idl create mode 100644 test/wpt/tests/interfaces/mediasession.idl create mode 100644 test/wpt/tests/interfaces/mediastream-recording.idl create mode 100644 test/wpt/tests/interfaces/model-element.idl create mode 100644 test/wpt/tests/interfaces/mst-content-hint.idl create mode 100644 test/wpt/tests/interfaces/navigation-timing.idl create mode 100644 test/wpt/tests/interfaces/netinfo.idl create mode 100644 test/wpt/tests/interfaces/notifications.idl create mode 100644 test/wpt/tests/interfaces/orientation-event.idl create mode 100644 test/wpt/tests/interfaces/orientation-sensor.idl create mode 100644 test/wpt/tests/interfaces/page-lifecycle.idl create mode 100644 test/wpt/tests/interfaces/paint-timing.idl create mode 100644 test/wpt/tests/interfaces/parakeet.tentative.idl create mode 100644 test/wpt/tests/interfaces/payment-handler.idl create mode 100644 test/wpt/tests/interfaces/payment-request.idl create mode 100644 test/wpt/tests/interfaces/performance-measure-memory.idl create mode 100644 test/wpt/tests/interfaces/performance-timeline.idl create mode 100644 test/wpt/tests/interfaces/periodic-background-sync.idl create mode 100644 test/wpt/tests/interfaces/permissions-policy.idl create mode 100644 test/wpt/tests/interfaces/permissions-request.idl create mode 100644 test/wpt/tests/interfaces/permissions-revoke.idl create mode 100644 test/wpt/tests/interfaces/permissions.idl create mode 100644 test/wpt/tests/interfaces/picture-in-picture.idl create mode 100644 test/wpt/tests/interfaces/pointerevents.idl create mode 100644 test/wpt/tests/interfaces/pointerlock.idl create mode 100644 test/wpt/tests/interfaces/portals.idl create mode 100644 test/wpt/tests/interfaces/prefer-current-tab.idl create mode 100644 test/wpt/tests/interfaces/prerendering-revamped.idl create mode 100644 test/wpt/tests/interfaces/presentation-api.idl create mode 100644 test/wpt/tests/interfaces/private-click-measurement.idl create mode 100644 test/wpt/tests/interfaces/proximity.idl create mode 100644 test/wpt/tests/interfaces/push-api.idl create mode 100644 test/wpt/tests/interfaces/raw-camera-access.idl create mode 100644 test/wpt/tests/interfaces/real-world-meshing.idl create mode 100644 test/wpt/tests/interfaces/referrer-policy.idl create mode 100644 test/wpt/tests/interfaces/remote-playback.idl create mode 100644 test/wpt/tests/interfaces/reporting.idl create mode 100644 test/wpt/tests/interfaces/requestStorageAccessFor.idl create mode 100644 test/wpt/tests/interfaces/requestidlecallback.idl create mode 100644 test/wpt/tests/interfaces/resize-observer.idl create mode 100644 test/wpt/tests/interfaces/resource-timing.idl create mode 100644 test/wpt/tests/interfaces/sanitizer-api.idl create mode 100644 test/wpt/tests/interfaces/sanitizer-api.tentative.idl create mode 100644 test/wpt/tests/interfaces/savedata.idl create mode 100644 test/wpt/tests/interfaces/scheduling-apis.idl create mode 100644 test/wpt/tests/interfaces/screen-capture.idl create mode 100644 test/wpt/tests/interfaces/screen-orientation.idl create mode 100644 test/wpt/tests/interfaces/screen-wake-lock.idl create mode 100644 test/wpt/tests/interfaces/scroll-animations.idl create mode 100644 test/wpt/tests/interfaces/scroll-to-text-fragment.idl create mode 100644 test/wpt/tests/interfaces/secure-payment-confirmation.idl create mode 100644 test/wpt/tests/interfaces/selection-api.idl create mode 100644 test/wpt/tests/interfaces/serial.idl create mode 100644 test/wpt/tests/interfaces/server-timing.idl create mode 100644 test/wpt/tests/interfaces/service-workers.idl create mode 100644 test/wpt/tests/interfaces/shape-detection-api.idl create mode 100644 test/wpt/tests/interfaces/shared-storage.idl create mode 100644 test/wpt/tests/interfaces/speech-api.idl create mode 100644 test/wpt/tests/interfaces/storage-access.idl create mode 100644 test/wpt/tests/interfaces/storage-buckets.idl create mode 100644 test/wpt/tests/interfaces/storage-buckets.tentative.idl create mode 100644 test/wpt/tests/interfaces/storage.idl create mode 100644 test/wpt/tests/interfaces/streams.idl create mode 100644 test/wpt/tests/interfaces/sub-apps.tentative.idl create mode 100644 test/wpt/tests/interfaces/svg-animations.idl create mode 100644 test/wpt/tests/interfaces/testutils.idl create mode 100644 test/wpt/tests/interfaces/text-detection-api.idl create mode 100644 test/wpt/tests/interfaces/touch-events.idl create mode 100644 test/wpt/tests/interfaces/trust-token-api.idl create mode 100644 test/wpt/tests/interfaces/trusted-types.idl create mode 100644 test/wpt/tests/interfaces/turtledove.idl create mode 100644 test/wpt/tests/interfaces/ua-client-hints.idl create mode 100644 test/wpt/tests/interfaces/uievents.idl create mode 100644 test/wpt/tests/interfaces/url.idl create mode 100644 test/wpt/tests/interfaces/urlpattern.idl create mode 100644 test/wpt/tests/interfaces/user-timing.idl create mode 100644 test/wpt/tests/interfaces/vibration.idl create mode 100644 test/wpt/tests/interfaces/video-rvfc.idl create mode 100644 test/wpt/tests/interfaces/virtual-keyboard.idl create mode 100644 test/wpt/tests/interfaces/virtual-keyboard.tentative.idl create mode 100644 test/wpt/tests/interfaces/wai-aria.idl create mode 100644 test/wpt/tests/interfaces/wasm-js-api.idl create mode 100644 test/wpt/tests/interfaces/wasm-web-api.idl create mode 100644 test/wpt/tests/interfaces/web-animations-2.idl create mode 100644 test/wpt/tests/interfaces/web-animations.idl create mode 100644 test/wpt/tests/interfaces/web-app-launch.idl create mode 100644 test/wpt/tests/interfaces/web-bluetooth.idl create mode 100644 test/wpt/tests/interfaces/web-locks.idl create mode 100644 test/wpt/tests/interfaces/web-nfc.idl create mode 100644 test/wpt/tests/interfaces/web-otp.idl create mode 100644 test/wpt/tests/interfaces/web-share.idl create mode 100644 test/wpt/tests/interfaces/webaudio.idl create mode 100644 test/wpt/tests/interfaces/webauthn.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-aac-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-av1-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-avc-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-flac-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-hevc-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-opus-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs-vp9-codec-registration.idl create mode 100644 test/wpt/tests/interfaces/webcodecs.idl create mode 100644 test/wpt/tests/interfaces/webcrypto-secure-curves.idl create mode 100644 test/wpt/tests/interfaces/webdriver.idl create mode 100644 test/wpt/tests/interfaces/webgl1.idl create mode 100644 test/wpt/tests/interfaces/webgl2.idl create mode 100644 test/wpt/tests/interfaces/webgpu.idl create mode 100644 test/wpt/tests/interfaces/webhid.idl create mode 100644 test/wpt/tests/interfaces/webidl.idl create mode 100644 test/wpt/tests/interfaces/webmidi.idl create mode 100644 test/wpt/tests/interfaces/webnn.idl create mode 100644 test/wpt/tests/interfaces/webrtc-encoded-transform.idl create mode 100644 test/wpt/tests/interfaces/webrtc-ice.idl create mode 100644 test/wpt/tests/interfaces/webrtc-identity.idl create mode 100644 test/wpt/tests/interfaces/webrtc-priority.idl create mode 100644 test/wpt/tests/interfaces/webrtc-stats.idl create mode 100644 test/wpt/tests/interfaces/webrtc-svc.idl create mode 100644 test/wpt/tests/interfaces/webrtc.idl create mode 100644 test/wpt/tests/interfaces/websockets.idl create mode 100644 test/wpt/tests/interfaces/webtransport.idl create mode 100644 test/wpt/tests/interfaces/webusb.idl create mode 100644 test/wpt/tests/interfaces/webvr.tentative.idl create mode 100644 test/wpt/tests/interfaces/webvtt.idl create mode 100644 test/wpt/tests/interfaces/webxr-ar-module.idl create mode 100644 test/wpt/tests/interfaces/webxr-depth-sensing.idl create mode 100644 test/wpt/tests/interfaces/webxr-dom-overlays.idl create mode 100644 test/wpt/tests/interfaces/webxr-gamepads-module.idl create mode 100644 test/wpt/tests/interfaces/webxr-hand-input.idl create mode 100644 test/wpt/tests/interfaces/webxr-hit-test.idl create mode 100644 test/wpt/tests/interfaces/webxr-lighting-estimation.idl create mode 100644 test/wpt/tests/interfaces/webxr.idl create mode 100644 test/wpt/tests/interfaces/webxrlayers.idl create mode 100644 test/wpt/tests/interfaces/window-controls-overlay.idl create mode 100644 test/wpt/tests/interfaces/window-management.idl create mode 100644 test/wpt/tests/interfaces/xhr.idl create mode 100644 test/wpt/tests/lint.ignore create mode 100644 test/wpt/tests/mimesniff/META.yml create mode 100644 test/wpt/tests/mimesniff/README.md create mode 100644 test/wpt/tests/mimesniff/media/media-sniff.window.js create mode 100644 test/wpt/tests/mimesniff/media/resources/flac.flac create mode 100644 test/wpt/tests/mimesniff/media/resources/make-vectors.sh create mode 100644 test/wpt/tests/mimesniff/media/resources/mp3-raw.mp3 create mode 100644 test/wpt/tests/mimesniff/media/resources/mp3-with-id3.mp3 create mode 100644 test/wpt/tests/mimesniff/media/resources/mp4.mp4 create mode 100644 test/wpt/tests/mimesniff/media/resources/ogg.ogg create mode 100644 test/wpt/tests/mimesniff/media/resources/wav.wav create mode 100644 test/wpt/tests/mimesniff/media/resources/webm.webm create mode 100644 test/wpt/tests/mimesniff/mime-types/README.md create mode 100644 test/wpt/tests/mimesniff/mime-types/charset-parameter.window.js create mode 100644 test/wpt/tests/mimesniff/mime-types/parsing.any.js create mode 100644 test/wpt/tests/mimesniff/mime-types/resources/generated-mime-types.json create mode 100644 test/wpt/tests/mimesniff/mime-types/resources/generated-mime-types.py create mode 100644 test/wpt/tests/mimesniff/mime-types/resources/mime-charset.py create mode 100644 test/wpt/tests/mimesniff/mime-types/resources/mime-groups.json create mode 100644 test/wpt/tests/mimesniff/mime-types/resources/mime-types.json create mode 100644 test/wpt/tests/resources/.htaccess create mode 100644 test/wpt/tests/resources/META.yml create mode 100644 test/wpt/tests/resources/SVGAnimationTestCase-testharness.js create mode 100644 test/wpt/tests/resources/accesskey.js create mode 100644 test/wpt/tests/resources/blank.html create mode 100644 test/wpt/tests/resources/channel.sub.js create mode 100644 test/wpt/tests/resources/check-layout-th.js create mode 100644 test/wpt/tests/resources/check-layout.js create mode 100644 test/wpt/tests/resources/chromium/README.md create mode 100644 test/wpt/tests/resources/chromium/contacts_manager_mock.js create mode 100644 test/wpt/tests/resources/chromium/content-index-helpers.js create mode 100644 test/wpt/tests/resources/chromium/enable-hyperlink-auditing.js create mode 100644 test/wpt/tests/resources/chromium/fake-hid.js create mode 100644 test/wpt/tests/resources/chromium/fake-serial.js create mode 100644 test/wpt/tests/resources/chromium/generic_sensor_mocks.js create mode 100644 test/wpt/tests/resources/chromium/generic_sensor_mocks.js.headers create mode 100644 test/wpt/tests/resources/chromium/mock-barcodedetection.js create mode 100644 test/wpt/tests/resources/chromium/mock-barcodedetection.js.headers create mode 100644 test/wpt/tests/resources/chromium/mock-battery-monitor.headers create mode 100644 test/wpt/tests/resources/chromium/mock-battery-monitor.js create mode 100644 test/wpt/tests/resources/chromium/mock-direct-sockets.js create mode 100644 test/wpt/tests/resources/chromium/mock-facedetection.js create mode 100644 test/wpt/tests/resources/chromium/mock-facedetection.js.headers create mode 100644 test/wpt/tests/resources/chromium/mock-idle-detection.js create mode 100644 test/wpt/tests/resources/chromium/mock-imagecapture.js create mode 100644 test/wpt/tests/resources/chromium/mock-managed-config.js create mode 100644 test/wpt/tests/resources/chromium/mock-pressure-service.js create mode 100644 test/wpt/tests/resources/chromium/mock-pressure-service.js.headers create mode 100644 test/wpt/tests/resources/chromium/mock-subapps.js create mode 100644 test/wpt/tests/resources/chromium/mock-textdetection.js create mode 100644 test/wpt/tests/resources/chromium/mock-textdetection.js.headers create mode 100644 test/wpt/tests/resources/chromium/nfc-mock.js create mode 100644 test/wpt/tests/resources/chromium/web-bluetooth-test.js create mode 100644 test/wpt/tests/resources/chromium/web-bluetooth-test.js.headers create mode 100644 test/wpt/tests/resources/chromium/webusb-child-test.js create mode 100644 test/wpt/tests/resources/chromium/webusb-child-test.js.headers create mode 100644 test/wpt/tests/resources/chromium/webusb-test.js create mode 100644 test/wpt/tests/resources/chromium/webusb-test.js.headers create mode 100644 test/wpt/tests/resources/chromium/webxr-test-math-helper.js create mode 100644 test/wpt/tests/resources/chromium/webxr-test-math-helper.js.headers create mode 100644 test/wpt/tests/resources/chromium/webxr-test.js create mode 100644 test/wpt/tests/resources/chromium/webxr-test.js.headers create mode 100644 test/wpt/tests/resources/declarative-shadow-dom-polyfill.js create mode 100644 test/wpt/tests/resources/idlharness-shadowrealm.js create mode 100644 test/wpt/tests/resources/idlharness.js create mode 100644 test/wpt/tests/resources/idlharness.js.headers create mode 100644 test/wpt/tests/resources/readme.md create mode 100644 test/wpt/tests/resources/sriharness.js create mode 100644 test/wpt/tests/resources/test-only-api.js create mode 100644 test/wpt/tests/resources/test-only-api.js.headers create mode 100644 test/wpt/tests/resources/test-only-api.m.js create mode 100644 test/wpt/tests/resources/test-only-api.m.js.headers create mode 100644 test/wpt/tests/resources/test/README.md create mode 100644 test/wpt/tests/resources/test/conftest.py create mode 100644 test/wpt/tests/resources/test/harness.html create mode 100644 test/wpt/tests/resources/test/idl-helper.js create mode 100644 test/wpt/tests/resources/test/nested-testharness.js create mode 100644 test/wpt/tests/resources/test/requirements.txt create mode 100644 test/wpt/tests/resources/test/tests/functional/abortsignal.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_async.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_async_bad_return.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_async_rejection.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_async_rejection_after_load.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_async_timeout.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_bad_return.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_count.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_err.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_err_multi.html create mode 100644 test/wpt/tests/resources/test/tests/functional/add_cleanup_sync_queue.html create mode 100644 test/wpt/tests/resources/test/tests/functional/api-tests-1.html create mode 100644 test/wpt/tests/resources/test/tests/functional/api-tests-2.html create mode 100644 test/wpt/tests/resources/test/tests/functional/api-tests-3.html create mode 100644 test/wpt/tests/resources/test/tests/functional/assert-array-equals.html create mode 100644 test/wpt/tests/resources/test/tests/functional/assert-throws-dom.html create mode 100644 test/wpt/tests/resources/test/tests/functional/force_timeout.html create mode 100644 test/wpt/tests/resources/test/tests/functional/generate-callback.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlDictionary/test_partial_interface_of.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_exposed_wildcard.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_immutable_prototype.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_interface_mixin.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_partial_interface_of.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_primary_interface_of.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlInterface/test_to_json_operation.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlNamespace/test_attribute.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlNamespace/test_operation.html create mode 100644 test/wpt/tests/resources/test/tests/functional/idlharness/IdlNamespace/test_partial_namespace.html create mode 100644 test/wpt/tests/resources/test/tests/functional/iframe-callback.html create mode 100644 test/wpt/tests/resources/test/tests/functional/iframe-consolidate-errors.html create mode 100644 test/wpt/tests/resources/test/tests/functional/iframe-consolidate-tests.html create mode 100644 test/wpt/tests/resources/test/tests/functional/iframe-msg.html create mode 100644 test/wpt/tests/resources/test/tests/functional/log-insertion.html create mode 100644 test/wpt/tests/resources/test/tests/functional/no-title.html create mode 100644 test/wpt/tests/resources/test/tests/functional/order.html create mode 100644 test/wpt/tests/resources/test/tests/functional/promise-async.html create mode 100644 test/wpt/tests/resources/test/tests/functional/promise-with-sync.html create mode 100644 test/wpt/tests/resources/test/tests/functional/promise.html create mode 100644 test/wpt/tests/resources/test/tests/functional/queue.html create mode 100644 test/wpt/tests/resources/test/tests/functional/setup-function-worker.js create mode 100644 test/wpt/tests/resources/test/tests/functional/setup-worker-service.html create mode 100644 test/wpt/tests/resources/test/tests/functional/single-page-test-fail.html create mode 100644 test/wpt/tests/resources/test/tests/functional/single-page-test-no-assertions.html create mode 100644 test/wpt/tests/resources/test/tests/functional/single-page-test-no-body.html create mode 100644 test/wpt/tests/resources/test/tests/functional/single-page-test-pass.html create mode 100644 test/wpt/tests/resources/test/tests/functional/step_wait.html create mode 100644 test/wpt/tests/resources/test/tests/functional/step_wait_func.html create mode 100644 test/wpt/tests/resources/test/tests/functional/task-scheduling-promise-test.html create mode 100644 test/wpt/tests/resources/test/tests/functional/task-scheduling-test.html create mode 100644 test/wpt/tests/resources/test/tests/functional/uncaught-exception-handle.html create mode 100644 test/wpt/tests/resources/test/tests/functional/uncaught-exception-ignore.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-dedicated-uncaught-allow.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-dedicated-uncaught-single.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-dedicated.sub.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-error.js create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-service.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-shared.html create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-uncaught-allow.js create mode 100644 test/wpt/tests/resources/test/tests/functional/worker-uncaught-single.js create mode 100644 test/wpt/tests/resources/test/tests/functional/worker.js create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlArray/is_json_type.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlDictionary/get_reverse_inheritance_stack.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlDictionary/test_partial_dictionary.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/constructors.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/default_to_json_operation.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/do_member_unscopable_asserts.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/get_interface_object.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/get_interface_object_owner.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/get_legacy_namespace.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/get_qualified_name.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/get_reverse_inheritance_stack.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/has_default_to_json_regular_operation.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/has_to_json_regular_operation.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/should_have_interface_object.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterface/test_primary_interface_of_undefined.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterfaceMember/is_to_json_regular_operation.html create mode 100644 test/wpt/tests/resources/test/tests/unit/IdlInterfaceMember/toString.html create mode 100644 test/wpt/tests/resources/test/tests/unit/assert_implements.html create mode 100644 test/wpt/tests/resources/test/tests/unit/assert_implements_optional.html create mode 100644 test/wpt/tests/resources/test/tests/unit/assert_object_equals.html create mode 100644 test/wpt/tests/resources/test/tests/unit/async-test-return-restrictions.html create mode 100644 test/wpt/tests/resources/test/tests/unit/basic.html create mode 100644 test/wpt/tests/resources/test/tests/unit/exceptional-cases-timeouts.html create mode 100644 test/wpt/tests/resources/test/tests/unit/exceptional-cases.html create mode 100644 test/wpt/tests/resources/test/tests/unit/format-value.html create mode 100644 test/wpt/tests/resources/test/tests/unit/helpers.js create mode 100644 test/wpt/tests/resources/test/tests/unit/late-test.html create mode 100644 test/wpt/tests/resources/test/tests/unit/promise_setup-timeout.html create mode 100644 test/wpt/tests/resources/test/tests/unit/promise_setup.html create mode 100644 test/wpt/tests/resources/test/tests/unit/single_test.html create mode 100644 test/wpt/tests/resources/test/tests/unit/test-return-restrictions.html create mode 100644 test/wpt/tests/resources/test/tests/unit/throwing-assertions.html create mode 100644 test/wpt/tests/resources/test/tests/unit/unpaired-surrogates.html create mode 100644 test/wpt/tests/resources/test/tox.ini create mode 100644 test/wpt/tests/resources/test/wptserver.py create mode 100644 test/wpt/tests/resources/testdriver-actions.js create mode 100644 test/wpt/tests/resources/testdriver-vendor.js create mode 100644 test/wpt/tests/resources/testdriver-vendor.js.headers create mode 100644 test/wpt/tests/resources/testdriver.js create mode 100644 test/wpt/tests/resources/testdriver.js.headers create mode 100644 test/wpt/tests/resources/testharness.js create mode 100644 test/wpt/tests/resources/testharness.js.headers create mode 100644 test/wpt/tests/resources/testharnessreport.js create mode 100644 test/wpt/tests/resources/testharnessreport.js.headers create mode 100644 test/wpt/tests/resources/webidl2/build.sh create mode 100644 test/wpt/tests/resources/webidl2/lib/README.md create mode 100644 test/wpt/tests/resources/webidl2/lib/VERSION.md create mode 100644 test/wpt/tests/resources/webidl2/lib/webidl2.js create mode 100644 test/wpt/tests/resources/webidl2/lib/webidl2.js.headers create mode 100644 test/wpt/tests/service-workers/META.yml create mode 100644 test/wpt/tests/service-workers/cache-storage/META.yml create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-abort.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-add.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-delete.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-keys-attributes-for-service-worker.https.html create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-keys.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-match.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-matchAll.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-put.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-storage-buckets.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-storage-keys.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-storage-match.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/cache-storage.https.any.js create mode 100644 test/wpt/tests/service-workers/cache-storage/common.https.window.js create mode 100644 test/wpt/tests/service-workers/cache-storage/crashtests/cache-response-clone.https.html create mode 100644 test/wpt/tests/service-workers/cache-storage/credentials.https.html create mode 100644 test/wpt/tests/service-workers/cache-storage/cross-partition.https.tentative.html create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/blank.html create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/cache-keys-attributes-for-service-worker.js create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/common-worker.js create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/credentials-iframe.html create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/credentials-worker.js create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/fetch-status.py create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/iframe.html create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/simple.txt create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/test-helpers.js create mode 100644 test/wpt/tests/service-workers/cache-storage/resources/vary.py create mode 100644 test/wpt/tests/service-workers/cache-storage/sandboxed-iframes.https.html create mode 100644 test/wpt/tests/service-workers/idlharness.https.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/Service-Worker-Allowed-header.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/close.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/extendable-message-event-constructor.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/extendable-message-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/fetch-on-the-right-interface.https.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/isSecureContext.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/isSecureContext.serviceworker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/postmessage.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/registration-attribute.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/close-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-constructor-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-loopback-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-ping-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-pong-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-utils.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-loopback-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-ping-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-pong-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/registration-attribute-newer-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/registration-attribute-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/unregister-controlling-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/unregister-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/service-worker-error-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/unregister.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ServiceWorkerGlobalScope/update.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/about-blank-replacement.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/activate-event-after-install-state-change.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/activation-after-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/activation.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/active.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-affect-other-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-fetch.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-not-using-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-shared-worker-fetch.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-using-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-with-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/claim-worker-fetch.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/client-id.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/client-navigate.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/client-url-of-blob-url-worker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-get-client-types.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-get-cross-origin.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-get-resultingClientId.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-get.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-blob-url-worker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-client-types.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-exact-controller.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-frozen.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-include-uncontrolled.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-on-evaluation.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall-order.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/clients-matchall.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controlled-dedicatedworker-postMessage.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controlled-iframe-postMessage.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controller-on-disconnect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controller-on-load.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controller-on-reload.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/controller-with-no-fetch-event-handler.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/credentials.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/data-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/data-transfer-files.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/dedicated-worker-service-worker-interception.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/detached-context.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/embed-and-object-are-not-intercepted.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/extendable-event-async-waituntil.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/extendable-event-waituntil.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-audio-tainting.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-double-write.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-image-cache.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-image.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-video-cache.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-video-with-range-request.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-canvas-tainting-video.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-cors-exposed-header-names.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-cors-xhr.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-csp.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-error.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-add-async.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-after-navigation-within-page.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-async-respond-with.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-handled.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-is-history-backward-navigation-manual.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-is-history-forward-navigation-manual.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-is-reload-iframe-navigation-manual.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-is-reload-navigation-manual.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-network-error.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-referrer-policy.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-argument.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-body-loaded-in-chunk.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-custom-response.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-partial-stream.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-readable-stream-chunk.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-readable-stream.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-response-body-with-invalid-chunk.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-respond-with-stops-propagation.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-throws-after-respond-with.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-within-sw-manual.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event-within-sw.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event.https.h2.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-frame-resource.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-header-visibility.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-mixed-content-to-inscope.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-mixed-content-to-outscope.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-css-base-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-css-cross-origin.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-css-images.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-fallback.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-no-freshness-headers.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-resources.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-xhr-sync-error.https.window.js create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-xhr-sync-on-worker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-xhr-sync.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-request-xhr.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-response-taint.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-response-xhr.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/fetch-waits-for-activate.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/getregistration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/getregistrations.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/global-serviceworker.https.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/historical.https.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/http-to-https-redirect-and-register.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/immutable-prototype-serviceworker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-cross-origin.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-data-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-mime-types.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-resource-map.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/import-scripts-updated-flag.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/indexeddb.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/install-event-type.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/installing.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/interface-requirements-sw.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/invalid-blobtype.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/invalid-header.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/iso-latin1-header.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/local-url-inherit-controller.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/mime-sniffing.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/current/current.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/current/test-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/incumbent/incumbent.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/incumbent/test-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/relevant/relevant.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/relevant/test-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/test-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/multi-globals/url-parsing.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multipart-image.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multiple-register.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/multiple-update.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigate-window.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-headers.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/broken-chunked-encoding.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/chunked-encoding.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/empty-preload-response-body.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/get-state.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/navigationPreload.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/request-headers.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resource-timing.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/broken-chunked-encoding-scope.asis create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/broken-chunked-encoding-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/chunked-encoding-scope.py create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/chunked-encoding-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/cookie.py create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/empty-preload-response-body-scope.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/empty-preload-response-body-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/get-state-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/helpers.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/navigation-preload-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/redirect-redirected.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/redirect-scope.py create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/redirect-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/request-headers-scope.py create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/request-headers-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/resource-timing-scope.py create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/resource-timing-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/samesite-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/samesite-sw-helper.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/samesite-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/resources/wait-for-activate-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/samesite-cookies.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-preload/samesite-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-redirect-body.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-redirect-resolution.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-redirect-to-http.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-sets-cookie.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-timing-extended.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/navigation-timing.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/nested-blob-url-workers.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/next-hop-protocol.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/no-dynamic-import-in-module.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/no-dynamic-import.any.js create mode 100644 test/wpt/tests/service-workers/service-worker/onactivate-script-error.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/oninstall-script-error.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/opaque-response-preloaded.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/opaque-script.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/partitioned-claim.tentative.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/partitioned-cookies.tentative.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/partitioned-getRegistrations.tentative.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/partitioned-matchAll.tentative.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/partitioned.tentative.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/performance-timeline.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postMessage-client-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage-blob-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage-from-waiting-serviceworker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage-msgport-to-client.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage-to-client-message-queue.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage-to-client.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/postmessage.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/ready.https.window.js create mode 100644 test/wpt/tests/service-workers/service-worker/redirected-response.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/referer.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/referrer-policy-header.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/referrer-toplevel-script-fetch.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/register-closed-window.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/register-default-scope.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/register-same-scope-different-script-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/register-wait-forever-in-install-worker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-basic.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-end-to-end.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-events.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-mime-types.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-schedule-job.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-scope-module-static-import.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-scope.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-script-module.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-script-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-script.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-security-error.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-service-worker-attributes.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/registration-updateviacache.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/rejections.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/request-end-to-end.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resource-timing-bodySize.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resource-timing-cross-origin.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resource-timing-fetch-variants.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resource-timing.sub.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/404.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-blank-dynamic-nested-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-blank-nested-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-frame.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-ping-frame.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-popup-frame.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-srcdoc-nested-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-uncontrolled-nested-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/about-blank-replacement-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/basic-module-2.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/basic-module.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/blank.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/bytecheck-worker-imported-script.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/bytecheck-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-blob-url-worker-fetch-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-nested-worker-fetch-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-nested-worker-fetch-parent-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-shared-worker-fetch-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-shared-worker-fetch-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-with-redirect-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-worker-fetch-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-worker-fetch-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/claim-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/classic-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-id-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-navigate-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-navigate-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-navigated-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-url-of-blob-url-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/client-url-of-blob-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-frame-freeze.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-client-types-frame-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-client-types-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-client-types-shared-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-client-types-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-cross-origin-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-other-origin.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-resultingClientId-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-get-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-blob-url-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-client-types-dedicated-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-client-types-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-client-types-shared-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-on-evaluation-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/clients-matchall-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/controlled-frame-postMessage.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/controlled-worker-late-postMessage.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/controlled-worker-postMessage.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/cors-approved.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/cors-approved.txt.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/cors-denied.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/create-blob-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/create-out-of-scope-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/echo-content.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/echo-cookie-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/echo-message-to-source-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embed-and-object-are-not-intercepted-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embed-image-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embed-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embed-navigation-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embedded-content-from-server.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/embedded-content-from-service-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/empty-but-slow-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/empty-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/empty.h2.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/empty.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/empty.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/enable-client-message-queue.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/end-to-end-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/events-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/extendable-event-async-waituntil.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/extendable-event-waituntil.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fail-on-fetch-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-access-control-login.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-access-control.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-canvas-tainting-double-write-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-canvas-tainting-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-canvas-tainting-tests.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-cors-exposed-header-names-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-cors-xhr-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-csp-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-csp-iframe.html.sub.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-add-async-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-after-navigation-within-page-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-async-respond-with-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-handled-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-network-error-controllee-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-network-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-network-fallback-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-argument-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-argument-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-body-loaded-in-chunk-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-custom-response-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-partial-stream-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-readable-stream-chunk-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-readable-stream-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-response-body-with-invalid-chunk-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-response-body-with-invalid-chunk-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-respond-with-stops-propagation-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-test-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-event-within-sw-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-header-visibility-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-mixed-content-iframe-inscope-to-inscope.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-mixed-content-iframe-inscope-to-outscope.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-mixed-content-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-base-url-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-base-url-style.css create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-base-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-cross.css create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-cross.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-same.css create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-same.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-read-contents.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-css-cross-origin-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-fallback-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-fallback-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-html-imports-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-html-imports-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-no-freshness-headers-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-no-freshness-headers-script.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-no-freshness-headers-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-redirect-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-resources-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-resources-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-sync-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-sync-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-sync-on-worker-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-sync-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-request-xhr-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-response-taint-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-response-xhr-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-response-xhr-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-response.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-response.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-rewrite-worker-referrer-policy.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-rewrite-worker-referrer-policy.js.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-rewrite-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-rewrite-worker.js.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-variants-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/fetch-waits-for-activate-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/form-poster.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/frame-for-getregistrations.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/get-resultingClientId-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/http-to-https-redirect-and-register-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/iframe-with-fetch-variants.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/iframe-with-image.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/immutable-prototype-serviceworker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-echo-cookie-worker-module.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-echo-cookie-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-mime-type-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-relative.xsl create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-404-after-update-plus-update-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-404-after-update.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-404.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-cross-origin-worker.sub.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-data-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-diff-resource-map-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-echo.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-get.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-redirect-import.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-redirect-on-second-time-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-redirect-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-resource-map-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-updated-flag-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/import-scripts-version.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/imported-classic-script.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/imported-module-script.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/indexeddb-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/install-event-type-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/install-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/interface-requirements-worker.sub.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-blobtype-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-blobtype-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-chunked-encoding-with-flush.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-chunked-encoding.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-header-iframe.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/invalid-header-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/iso-latin1-header-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/iso-latin1-header-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/load_worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/loaded.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/local-url-inherit-controller-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/local-url-inherit-controller-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/location-setter.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/malformed-http-response.asis create mode 100644 test/wpt/tests/service-workers/service-worker/resources/malformed-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/message-vs-microtask.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/mime-sniffing-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/mime-type-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/mint-new-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/missing.asis create mode 100644 test/wpt/tests/service-workers/service-worker/resources/module-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/multipart-image-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/multipart-image-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/multipart-image.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigate-window-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-headers-server.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-body-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-body.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-other-origin.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-out-scope.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-scope1.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-scope2.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-to-http-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-redirect-to-http-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-timing-worker-extended.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/navigation-timing-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested-blob-url-worker-created-from-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested-blob-url-workers.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested-iframe-parent.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested-parent.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested-worker-created-from-blob-url-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/nested_load_worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/no-dynamic-import.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/notification_icon.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/object-image-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/object-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/object-navigation-is-not-intercepted-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-throw-error-from-nested-event-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-throw-error-then-cancel-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-throw-error-then-prevent-default-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-throw-error-with-empty-onerror-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-throw-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onactivate-waituntil-forever.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onfetch-waituntil-forever.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-throw-error-from-nested-event-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-throw-error-then-cancel-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-throw-error-then-prevent-default-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-throw-error-with-empty-onerror-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-throw-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-waituntil-forever.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/oninstall-waituntil-throw-error-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/onparse-infiniteloop-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-response-being-preloaded-xhr.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-response-preloaded-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-response-preloaded-xhr.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-script-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-script-large.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-script-small.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/opaque-script-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/other.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/override_assert_object_equals.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-cookies-3p-credentialless-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-cookies-3p-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-cookies-3p-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-cookies-3p-window.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-cookies-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-iframe-claim.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-nested-iframe-child.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-nested-iframe-parent.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe-getRegistrations.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe-matchAll.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-service-worker-third-party-window.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-storage-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/partitioned-utils.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/pass-through-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/pass.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/performance-timeline-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-blob-url.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-dictionary-transferables-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-echo-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-fetched-text.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-msgport-to-client-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-on-load-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-to-client-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-transferables-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/postmessage-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/range-request-to-different-origins-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/range-request-with-different-cors-modes-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/redirect-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/redirect.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/referer-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/referrer-policy-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/register-closed-window-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/register-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/register-rewrite-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-tests-mime-types.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-tests-scope.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-tests-script-url.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-tests-script.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-tests-security-error.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/registration-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/reject-install-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/reply-to-message.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/request-end-to-end-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/request-headers.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/resource-timing-iframe.sub.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/resource-timing-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/respond-then-throw-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/respond-with-body-accessed-response-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/respond-with-body-accessed-response-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/respond-with-body-accessed-response.jsonp create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sample-worker-interceptor.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sample.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sample.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sample.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-iframe.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/sandboxed-iframe-navigator-serviceworker-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope1/module-worker-importing-redirect-to-scope2.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope1/module-worker-importing-scope2.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope1/redirect.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope2/import-scripts-echo.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope2/imported-module-script.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope2/simple.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/scope2/worker_interception_redirect_webworker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/secure-context-service-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/secure-context/sender.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/secure-context/window.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-csp-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-header.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-interception-dynamic-import-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-interception-network-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-interception-service-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/service-worker-interception-static-import-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/silence.oga create mode 100644 test/wpt/tests/service-workers/service-worker/resources/simple-intercept-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/simple-intercept-worker.js.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/simple.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/simple.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/skip-waiting-installed-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/skip-waiting-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/square.png create mode 100644 test/wpt/tests/service-workers/service-worker/resources/square.png.sub.headers create mode 100644 test/wpt/tests/service-workers/service-worker/resources/stalling-service-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/subdir/blank.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/subdir/import-scripts-echo.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/subdir/simple.txt create mode 100644 test/wpt/tests/service-workers/service-worker/resources/subdir/worker_interception_redirect_webworker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/success.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/svg-target-reftest-001-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/svg-target-reftest-001.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/svg-target-reftest-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/test-helpers.sub.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/test-request-headers-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/test-request-headers-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/test-request-mode-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/test-request-mode-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/testharness-helpers.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/trickle.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/type-check-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/unregister-controller-page.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/unregister-immediately-helpers.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/unregister-rewrite-worker.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-claim-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-during-installation-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-during-installation-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-fetch-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-max-aged-worker-imported-script.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-max-aged-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-missing-import-scripts-imported-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-missing-import-scripts-main-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-nocookie-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-recovery-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-registration-with-type.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-smaller-body-after-update-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-smaller-body-before-update-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-worker-from-file.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update-worker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update/update-after-oneday.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/update_shell.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/vtt-frame.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/wait-forever-in-install-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/websocket-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/websocket.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/window-opener.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/windowclient-navigate-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-client-id-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-fetching-cross-origin.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-interception-redirect-serviceworker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-interception-redirect-webworker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-load-interceptor.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker-testharness.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/worker_interception_redirect_webworker.py create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xhr-content-length-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xhr-iframe.html create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xhr-response-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xsl-base-url-iframe.xml create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xsl-base-url-worker.js create mode 100644 test/wpt/tests/service-workers/service-worker/resources/xslt-pass.xsl create mode 100644 test/wpt/tests/service-workers/service-worker/respond-with-body-accessed-response.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/same-site-cookies.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/sandboxed-iframe-fetch-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/sandboxed-iframe-navigator-serviceworker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/secure-context.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/service-worker-csp-connect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/service-worker-csp-default.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/service-worker-csp-script.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/service-worker-header.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/serviceworker-message-event-historical.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/serviceworkerobject-scripturl.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/skip-waiting-installed.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/skip-waiting-using-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/skip-waiting-without-client.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/skip-waiting-without-using-registration.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/skip-waiting.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/state.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/svg-target-reftest.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/synced-state.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/README.md create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/resources/direct.txt create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/resources/simple-test-for-condition-main-resource.html create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/resources/simple.html create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/resources/static-router-sw.js create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/resources/test-helpers.sub.js create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/static-router-main-resource.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/tentative/static-router/static-router-subresource.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/uncontrolled-page.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-controller.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-immediately-before-installed.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-immediately-during-extendable-events.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-immediately.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-then-register-new-script.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister-then-register.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/unregister.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-after-navigation-fetch-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-after-navigation-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-after-oneday.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-bytecheck-cors-import.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-bytecheck.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-import-scripts.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-missing-import-scripts.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-module-request-mode.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-no-cache-request-headers.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-not-allowed.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-on-navigation.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-recovery.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-registration-with-type.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update-result.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/update.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/waiting.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/websocket-in-service-worker.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/websocket.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/webvtt-cross-origin.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/windowclient-navigate.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/worker-client-id.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/worker-in-sandboxed-iframe-by-csp-fetch-event.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/worker-interception-redirect.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/worker-interception.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/xhr-content-length.https.window.js create mode 100644 test/wpt/tests/service-workers/service-worker/xhr-response-url.https.html create mode 100644 test/wpt/tests/service-workers/service-worker/xsl-base-url.https.html create mode 100644 test/wpt/tests/storage/META.yml create mode 100644 test/wpt/tests/storage/README.md create mode 100644 test/wpt/tests/storage/buckets/META.yml create mode 100644 test/wpt/tests/storage/buckets/bucket-quota-indexeddb.tentative.https.any.js create mode 100644 test/wpt/tests/storage/buckets/bucket-storage-policy.tentative.https.any.js create mode 100644 test/wpt/tests/storage/buckets/resources/cached-resource.txt create mode 100644 test/wpt/tests/storage/buckets/resources/util.js create mode 100644 test/wpt/tests/storage/estimate-indexeddb.https.any.js create mode 100644 test/wpt/tests/storage/estimate-parallel.https.any.js create mode 100644 test/wpt/tests/storage/estimate-usage-details-caches.https.tentative.any.js create mode 100644 test/wpt/tests/storage/estimate-usage-details-indexeddb.https.tentative.any.js create mode 100644 test/wpt/tests/storage/estimate-usage-details-service-workers.https.tentative.window.js create mode 100644 test/wpt/tests/storage/estimate-usage-details.https.tentative.any.js create mode 100644 test/wpt/tests/storage/helpers.js create mode 100644 test/wpt/tests/storage/idlharness.https.any.js create mode 100644 test/wpt/tests/storage/opaque-origin.https.window.js create mode 100644 test/wpt/tests/storage/partitioned-estimate-usage-details-caches.tentative.https.sub.html create mode 100644 test/wpt/tests/storage/partitioned-estimate-usage-details-indexeddb.tentative.https.sub.html create mode 100644 test/wpt/tests/storage/partitioned-estimate-usage-details-service-workers.tentative.https.sub.html create mode 100644 test/wpt/tests/storage/permission-query.https.any.js create mode 100644 test/wpt/tests/storage/persist-permission-manual.https.html create mode 100644 test/wpt/tests/storage/persisted.https.any.js create mode 100644 test/wpt/tests/storage/quotachange-in-detached-iframe.tentative.https.html create mode 100644 test/wpt/tests/storage/resources/partitioned-estimate-usage-details-caches-helper-frame.html create mode 100644 test/wpt/tests/storage/resources/partitioned-estimate-usage-details-indexeddb-helper-frame.html create mode 100644 test/wpt/tests/storage/resources/partitioned-estimate-usage-details-service-workers-helper-frame.html create mode 100644 test/wpt/tests/storage/resources/worker.js create mode 100644 test/wpt/tests/storage/storagemanager-estimate.https.any.js create mode 100644 test/wpt/tests/storage/storagemanager-persist-persisted-match.https.any.js create mode 100644 test/wpt/tests/storage/storagemanager-persist.https.window.js create mode 100644 test/wpt/tests/storage/storagemanager-persist.https.worker.js create mode 100644 test/wpt/tests/storage/storagemanager-persisted.https.any.js create mode 100644 test/wpt/tests/websockets/Close-1000-reason.any.js create mode 100644 test/wpt/tests/websockets/Close-1000-verify-code.any.js create mode 100644 test/wpt/tests/websockets/Close-1000.any.js create mode 100644 test/wpt/tests/websockets/Close-1005-verify-code.any.js create mode 100644 test/wpt/tests/websockets/Close-1005.any.js create mode 100644 test/wpt/tests/websockets/Close-2999-reason.any.js create mode 100644 test/wpt/tests/websockets/Close-3000-reason.any.js create mode 100644 test/wpt/tests/websockets/Close-3000-verify-code.any.js create mode 100644 test/wpt/tests/websockets/Close-4999-reason.any.js create mode 100644 test/wpt/tests/websockets/Close-Reason-124Bytes.any.js create mode 100644 test/wpt/tests/websockets/Close-delayed.any.js create mode 100644 test/wpt/tests/websockets/Close-onlyReason.any.js create mode 100644 test/wpt/tests/websockets/Close-readyState-Closed.any.js create mode 100644 test/wpt/tests/websockets/Close-readyState-Closing.any.js create mode 100644 test/wpt/tests/websockets/Close-reason-unpaired-surrogates.any.js create mode 100644 test/wpt/tests/websockets/Close-server-initiated-close.any.js create mode 100644 test/wpt/tests/websockets/Close-undefined.any.js create mode 100644 test/wpt/tests/websockets/Create-asciiSep-protocol-string.any.js create mode 100644 test/wpt/tests/websockets/Create-blocked-port.any.js create mode 100644 test/wpt/tests/websockets/Create-extensions-empty.any.js create mode 100644 test/wpt/tests/websockets/Create-http-urls.any.js create mode 100644 test/wpt/tests/websockets/Create-invalid-urls.any.js create mode 100644 test/wpt/tests/websockets/Create-non-absolute-url.any.js create mode 100644 test/wpt/tests/websockets/Create-nonAscii-protocol-string.any.js create mode 100644 test/wpt/tests/websockets/Create-on-worker-shutdown.any.js create mode 100644 test/wpt/tests/websockets/Create-protocol-with-space.any.js create mode 100644 test/wpt/tests/websockets/Create-protocols-repeated-case-insensitive.any.js create mode 100644 test/wpt/tests/websockets/Create-protocols-repeated.any.js create mode 100644 test/wpt/tests/websockets/Create-url-with-space.any.js create mode 100644 test/wpt/tests/websockets/Create-url-with-windows-1252-encoding.html create mode 100644 test/wpt/tests/websockets/Create-valid-url-array-protocols.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url-binaryType-blob.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url-protocol-empty.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url-protocol-setCorrectly.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url-protocol-string.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url-protocol.any.js create mode 100644 test/wpt/tests/websockets/Create-valid-url.any.js create mode 100644 test/wpt/tests/websockets/META.yml create mode 100644 test/wpt/tests/websockets/README.md create mode 100644 test/wpt/tests/websockets/Send-0byte-data.any.js create mode 100644 test/wpt/tests/websockets/Send-65K-data.any.js create mode 100644 test/wpt/tests/websockets/Send-before-open.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-65K-arraybuffer.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybuffer.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-float32.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-float64.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-int16-offset.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-int32.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-int8.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-uint16-offset-length.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-uint32-offset.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-uint8-offset-length.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-arraybufferview-uint8-offset.any.js create mode 100644 test/wpt/tests/websockets/Send-binary-blob.any.js create mode 100644 test/wpt/tests/websockets/Send-data.any.js create mode 100644 test/wpt/tests/websockets/Send-data.worker.js create mode 100644 test/wpt/tests/websockets/Send-null.any.js create mode 100644 test/wpt/tests/websockets/Send-paired-surrogates.any.js create mode 100644 test/wpt/tests/websockets/Send-unicode-data.any.js create mode 100644 test/wpt/tests/websockets/Send-unpaired-surrogates.any.js create mode 100644 test/wpt/tests/websockets/back-forward-cache-with-closed-websocket-connection-ccns.tentative.window.js create mode 100644 test/wpt/tests/websockets/back-forward-cache-with-closed-websocket-connection.window.js create mode 100644 test/wpt/tests/websockets/back-forward-cache-with-open-websocket-connection-ccns.tentative.window.js create mode 100644 test/wpt/tests/websockets/back-forward-cache-with-open-websocket-connection.window.js create mode 100644 test/wpt/tests/websockets/basic-auth.any.js create mode 100644 test/wpt/tests/websockets/binary/001.html create mode 100644 test/wpt/tests/websockets/binary/002.html create mode 100644 test/wpt/tests/websockets/binary/004.html create mode 100644 test/wpt/tests/websockets/binary/005.html create mode 100644 test/wpt/tests/websockets/binaryType-wrong-value.any.js create mode 100644 test/wpt/tests/websockets/bufferedAmount-unchanged-by-sync-xhr.any.js create mode 100644 test/wpt/tests/websockets/close-invalid.any.js create mode 100644 test/wpt/tests/websockets/closing-handshake/002.html create mode 100644 test/wpt/tests/websockets/closing-handshake/003.html create mode 100644 test/wpt/tests/websockets/closing-handshake/004.html create mode 100644 test/wpt/tests/websockets/constants.sub.js create mode 100644 test/wpt/tests/websockets/constructor.any.js create mode 100644 test/wpt/tests/websockets/constructor/001.html create mode 100644 test/wpt/tests/websockets/constructor/004.html create mode 100644 test/wpt/tests/websockets/constructor/005.html create mode 100644 test/wpt/tests/websockets/constructor/006.html create mode 100644 test/wpt/tests/websockets/constructor/007.html create mode 100644 test/wpt/tests/websockets/constructor/008.html create mode 100644 test/wpt/tests/websockets/constructor/009.html create mode 100644 test/wpt/tests/websockets/constructor/010.html create mode 100644 test/wpt/tests/websockets/constructor/011.html create mode 100644 test/wpt/tests/websockets/constructor/012.html create mode 100644 test/wpt/tests/websockets/constructor/013.html create mode 100644 test/wpt/tests/websockets/constructor/014.html create mode 100644 test/wpt/tests/websockets/constructor/016.html create mode 100644 test/wpt/tests/websockets/constructor/017.html create mode 100644 test/wpt/tests/websockets/constructor/018.html create mode 100644 test/wpt/tests/websockets/constructor/019.html create mode 100644 test/wpt/tests/websockets/constructor/020.html create mode 100644 test/wpt/tests/websockets/constructor/021.html create mode 100644 test/wpt/tests/websockets/constructor/022.html create mode 100644 test/wpt/tests/websockets/cookies/001.html create mode 100644 test/wpt/tests/websockets/cookies/002.html create mode 100644 test/wpt/tests/websockets/cookies/003.html create mode 100644 test/wpt/tests/websockets/cookies/004.html create mode 100644 test/wpt/tests/websockets/cookies/005.html create mode 100644 test/wpt/tests/websockets/cookies/006.html create mode 100644 test/wpt/tests/websockets/cookies/007.html create mode 100644 test/wpt/tests/websockets/cookies/support/set-cookie.py create mode 100644 test/wpt/tests/websockets/cookies/support/websocket-cookies-helper.sub.js create mode 100644 test/wpt/tests/websockets/cookies/third-party-cookie-accepted.https.html create mode 100644 test/wpt/tests/websockets/eventhandlers.any.js create mode 100644 test/wpt/tests/websockets/extended-payload-length.html create mode 100644 test/wpt/tests/websockets/handlers/basic_auth_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/delayed-passive-close_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo-cookie_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo-query_v13_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo-query_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo_close_data_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo_exit_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo_raw_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/echo_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/empty-message_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/handshake_no_extensions_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/handshake_no_protocol_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/handshake_protocol_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/handshake_sleep_2_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/invalid_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/msg_channel_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/origin_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/protocol_array_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/protocol_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/receive-backpressure_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/receive-many-with-backpressure_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/referrer_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/send-backpressure_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/set-cookie-secure_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/set-cookie_http_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/set-cookie_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/set-cookies-samesite_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/simple_handshake_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/sleep_10_v13_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/stash_responder_blocking_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/stash_responder_wsh.py create mode 100644 test/wpt/tests/websockets/handlers/wrong_accept_key_wsh.py create mode 100644 test/wpt/tests/websockets/idlharness.any.js create mode 100644 test/wpt/tests/websockets/interfaces/CloseEvent/clean-close.html create mode 100644 test/wpt/tests/websockets/interfaces/CloseEvent/constructor.html create mode 100644 test/wpt/tests/websockets/interfaces/CloseEvent/historical.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-arraybuffer.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-blob.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-defineProperty-getter.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-defineProperty-setter.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-deleting.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-getting.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-initial.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-large.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-readonly.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-unicode.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-basic.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-connecting.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-multiple.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-nested.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-replace.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/close/close-return.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/002.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/003.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/004.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/005.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/constants/006.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/002.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/003.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/004.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/006.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/007.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/008.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/009.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/010.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/011.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/012.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/013.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/014.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/015.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/016.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/017.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/018.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/019.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/events/020.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/extensions/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/protocol/protocol-initial.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/002.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/003.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/004.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/005.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/006.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/007.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/readyState/008.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/002.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/003.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/004.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/005.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/006.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/007.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/008.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/009.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/010.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/011.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/send/012.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/001.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/002.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/003.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/004.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/005.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/006.html create mode 100644 test/wpt/tests/websockets/interfaces/WebSocket/url/resolve.html create mode 100644 test/wpt/tests/websockets/keeping-connection-open/001.html create mode 100644 test/wpt/tests/websockets/mixed-content.https.any.js create mode 100644 test/wpt/tests/websockets/multi-globals/message-received.html create mode 100644 test/wpt/tests/websockets/multi-globals/support/incumbent.sub.html create mode 100644 test/wpt/tests/websockets/multi-globals/support/relevant.html create mode 100644 test/wpt/tests/websockets/multi-globals/url-parsing/current/current.html create mode 100644 test/wpt/tests/websockets/multi-globals/url-parsing/incumbent/incumbent.html create mode 100644 test/wpt/tests/websockets/multi-globals/url-parsing/url-parsing.html create mode 100644 test/wpt/tests/websockets/opening-handshake/001.html create mode 100644 test/wpt/tests/websockets/opening-handshake/002.html create mode 100644 test/wpt/tests/websockets/opening-handshake/003-sets-origin.worker.js create mode 100644 test/wpt/tests/websockets/opening-handshake/003.html create mode 100644 test/wpt/tests/websockets/opening-handshake/005.html create mode 100644 test/wpt/tests/websockets/referrer.any.js create mode 100644 test/wpt/tests/websockets/remove-own-iframe-during-onerror.window.js create mode 100644 test/wpt/tests/websockets/resources/websockets-test-helpers.sub.js create mode 100644 test/wpt/tests/websockets/security/001.html create mode 100644 test/wpt/tests/websockets/security/002.html create mode 100644 test/wpt/tests/websockets/security/check.py create mode 100644 test/wpt/tests/websockets/send-many-64K-messages-with-backpressure.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/README.md create mode 100644 test/wpt/tests/websockets/stream/tentative/abort.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/backpressure-receive.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/backpressure-send.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/close.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/constructor.any.js create mode 100644 test/wpt/tests/websockets/stream/tentative/resources/url-constants.js create mode 100644 test/wpt/tests/websockets/unload-a-document/001-1.html create mode 100644 test/wpt/tests/websockets/unload-a-document/001-2.html create mode 100644 test/wpt/tests/websockets/unload-a-document/001.html create mode 100644 test/wpt/tests/websockets/unload-a-document/002-1.html create mode 100644 test/wpt/tests/websockets/unload-a-document/002-2.html create mode 100644 test/wpt/tests/websockets/unload-a-document/002.html create mode 100644 test/wpt/tests/websockets/unload-a-document/003.html create mode 100644 test/wpt/tests/websockets/unload-a-document/004.html create mode 100644 test/wpt/tests/websockets/unload-a-document/005-1.html create mode 100644 test/wpt/tests/websockets/unload-a-document/005.html create mode 100644 test/wpt/tests/wpt create mode 100644 test/wpt/tests/wpt.py create mode 100644 test/wpt/tests/xhr/META.yml create mode 100644 test/wpt/tests/xhr/README.md create mode 100644 test/wpt/tests/xhr/XMLHttpRequest-withCredentials.any.js create mode 100644 test/wpt/tests/xhr/abort-after-receive.any.js create mode 100644 test/wpt/tests/xhr/abort-after-send.any.js create mode 100644 test/wpt/tests/xhr/abort-after-stop.window.js create mode 100644 test/wpt/tests/xhr/abort-after-timeout.any.js create mode 100644 test/wpt/tests/xhr/abort-during-done.window.js create mode 100644 test/wpt/tests/xhr/abort-during-headers-received.window.js create mode 100644 test/wpt/tests/xhr/abort-during-loading.window.js create mode 100644 test/wpt/tests/xhr/abort-during-open.any.js create mode 100644 test/wpt/tests/xhr/abort-during-readystatechange.any.js create mode 100644 test/wpt/tests/xhr/abort-during-unsent.any.js create mode 100644 test/wpt/tests/xhr/abort-during-upload.any.js create mode 100644 test/wpt/tests/xhr/abort-event-abort.any.js create mode 100644 test/wpt/tests/xhr/abort-event-listeners.any.js create mode 100644 test/wpt/tests/xhr/abort-event-loadend.any.js create mode 100644 test/wpt/tests/xhr/abort-event-order.htm create mode 100644 test/wpt/tests/xhr/abort-upload-event-abort.any.js create mode 100644 test/wpt/tests/xhr/abort-upload-event-loadend.any.js create mode 100644 test/wpt/tests/xhr/access-control-and-redirects-async-same-origin.any.js create mode 100644 test/wpt/tests/xhr/access-control-and-redirects-async.any.js create mode 100644 test/wpt/tests/xhr/access-control-and-redirects.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-access-control-origin-header-data-url.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-access-control-origin-header.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-async.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-non-cors-safelisted-method-async.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-non-cors-safelisted-method.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-preflight-cache-invalidation-by-header.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-preflight-cache-invalidation-by-method.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-preflight-cache-timeout.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-preflight-cache.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow-star.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-allow.any.js create mode 100644 test/wpt/tests/xhr/access-control-basic-cors-safelisted-request-headers.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-cors-safelisted-response-headers.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-get-fail-non-simple.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-non-cors-safelisted-content-type.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-post-success-no-content-type.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-post-with-non-cors-safelisted-content-type.htm create mode 100644 test/wpt/tests/xhr/access-control-basic-preflight-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-expose-headers-on-redirect.html create mode 100644 test/wpt/tests/xhr/access-control-preflight-async-header-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-async-method-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-async-not-supported.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-credential-async.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-credential-sync.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-headers-async.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-headers-sync.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-allow-headers-returns-star.any.js create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-header-lowercase.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-header-returns-origin.any.js create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-header-sorted.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-headers-origin.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-invalid-status-301.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-invalid-status-400.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-invalid-status-501.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-request-must-not-contain-cookie.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-sync-header-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-sync-method-denied.htm create mode 100644 test/wpt/tests/xhr/access-control-preflight-sync-not-supported.htm create mode 100644 test/wpt/tests/xhr/access-control-recursive-failed-request.htm create mode 100644 test/wpt/tests/xhr/access-control-response-with-body-sync.htm create mode 100644 test/wpt/tests/xhr/access-control-response-with-body.htm create mode 100644 test/wpt/tests/xhr/access-control-response-with-exposed-headers.htm create mode 100644 test/wpt/tests/xhr/access-control-sandboxed-iframe-allow-origin-null.htm create mode 100644 test/wpt/tests/xhr/access-control-sandboxed-iframe-allow.htm create mode 100644 test/wpt/tests/xhr/access-control-sandboxed-iframe-denied-without-wildcard.htm create mode 100644 test/wpt/tests/xhr/access-control-sandboxed-iframe-denied.htm create mode 100644 test/wpt/tests/xhr/allow-lists-starting-with-comma.htm create mode 100644 test/wpt/tests/xhr/anonymous-mode-unsupported.htm create mode 100644 test/wpt/tests/xhr/blob-range.any.js create mode 100644 test/wpt/tests/xhr/close-worker-with-xhr-in-progress.html create mode 100644 test/wpt/tests/xhr/content-type-unmodified.any.js create mode 100644 test/wpt/tests/xhr/cookies.http.html create mode 100644 test/wpt/tests/xhr/cors-expose-star.sub.any.js create mode 100644 test/wpt/tests/xhr/cors-upload.any.js create mode 100644 test/wpt/tests/xhr/data-uri.htm create mode 100644 test/wpt/tests/xhr/event-abort.any.js create mode 100644 test/wpt/tests/xhr/event-error-order.sub.html create mode 100644 test/wpt/tests/xhr/event-error.sub.any.js create mode 100644 test/wpt/tests/xhr/event-load.any.js create mode 100644 test/wpt/tests/xhr/event-loadend.any.js create mode 100644 test/wpt/tests/xhr/event-loadstart-upload.any.js create mode 100644 test/wpt/tests/xhr/event-loadstart.any.js create mode 100644 test/wpt/tests/xhr/event-progress.any.js create mode 100644 test/wpt/tests/xhr/event-readystate-sync-open.any.js create mode 100644 test/wpt/tests/xhr/event-readystatechange-loaded.any.js create mode 100644 test/wpt/tests/xhr/event-timeout-order.any.js create mode 100644 test/wpt/tests/xhr/event-timeout.any.js create mode 100644 test/wpt/tests/xhr/event-upload-progress-crossorigin.any.js create mode 100644 test/wpt/tests/xhr/event-upload-progress.any.js create mode 100644 test/wpt/tests/xhr/firing-events-http-content-length.html create mode 100644 test/wpt/tests/xhr/firing-events-http-no-content-length.html create mode 100644 test/wpt/tests/xhr/folder.txt create mode 100644 test/wpt/tests/xhr/formdata.html create mode 100644 test/wpt/tests/xhr/formdata/append-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/append.any.js create mode 100644 test/wpt/tests/xhr/formdata/constructor-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/constructor-submitter.html create mode 100644 test/wpt/tests/xhr/formdata/constructor.any.js create mode 100644 test/wpt/tests/xhr/formdata/delete-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/delete.any.js create mode 100644 test/wpt/tests/xhr/formdata/foreach.any.js create mode 100644 test/wpt/tests/xhr/formdata/get-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/get.any.js create mode 100644 test/wpt/tests/xhr/formdata/has-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/has.any.js create mode 100644 test/wpt/tests/xhr/formdata/iteration.any.js create mode 100644 test/wpt/tests/xhr/formdata/set-blob.any.js create mode 100644 test/wpt/tests/xhr/formdata/set-formelement.html create mode 100644 test/wpt/tests/xhr/formdata/set.any.js create mode 100644 test/wpt/tests/xhr/getallresponseheaders-cookies.htm create mode 100644 test/wpt/tests/xhr/getallresponseheaders-status.htm create mode 100644 test/wpt/tests/xhr/getallresponseheaders.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-case-insensitive.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-chunked-trailer.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-cookies-and-more.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-error-state.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-server-date.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-special-characters.htm create mode 100644 test/wpt/tests/xhr/getresponseheader-unsent-opened-state.htm create mode 100644 test/wpt/tests/xhr/getresponseheader.any.js create mode 100644 test/wpt/tests/xhr/header-user-agent-async.htm create mode 100644 test/wpt/tests/xhr/header-user-agent-sync.htm create mode 100644 test/wpt/tests/xhr/headers-normalize-response.htm create mode 100644 test/wpt/tests/xhr/historical.html create mode 100644 test/wpt/tests/xhr/idlharness.any.js create mode 100644 test/wpt/tests/xhr/json.any.js create mode 100644 test/wpt/tests/xhr/loadstart-and-state.html create mode 100644 test/wpt/tests/xhr/open-after-abort.htm create mode 100644 test/wpt/tests/xhr/open-after-setrequestheader.htm create mode 100644 test/wpt/tests/xhr/open-after-stop.window.js create mode 100644 test/wpt/tests/xhr/open-during-abort-event.htm create mode 100644 test/wpt/tests/xhr/open-during-abort-processing.htm create mode 100644 test/wpt/tests/xhr/open-during-abort.htm create mode 100644 test/wpt/tests/xhr/open-method-bogus.htm create mode 100644 test/wpt/tests/xhr/open-method-case-insensitive.htm create mode 100644 test/wpt/tests/xhr/open-method-case-sensitive.htm create mode 100644 test/wpt/tests/xhr/open-method-insecure.htm create mode 100644 test/wpt/tests/xhr/open-method-responsetype-set-sync.htm create mode 100644 test/wpt/tests/xhr/open-open-send.htm create mode 100644 test/wpt/tests/xhr/open-open-sync-send.htm create mode 100644 test/wpt/tests/xhr/open-parameters-toString.htm create mode 100644 test/wpt/tests/xhr/open-referer.htm create mode 100644 test/wpt/tests/xhr/open-send-during-abort.htm create mode 100644 test/wpt/tests/xhr/open-send-open.htm create mode 100644 test/wpt/tests/xhr/open-sync-open-send.htm create mode 100644 test/wpt/tests/xhr/open-url-about-blank-window.htm create mode 100644 test/wpt/tests/xhr/open-url-base-inserted-after-open.htm create mode 100644 test/wpt/tests/xhr/open-url-base-inserted.htm create mode 100644 test/wpt/tests/xhr/open-url-base.htm create mode 100644 test/wpt/tests/xhr/open-url-encoding.htm create mode 100644 test/wpt/tests/xhr/open-url-fragment.htm create mode 100644 test/wpt/tests/xhr/open-url-javascript-window-2.htm create mode 100644 test/wpt/tests/xhr/open-url-javascript-window.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window-2.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window-3.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window-4.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window-5.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window-6.htm create mode 100644 test/wpt/tests/xhr/open-url-multi-window.htm create mode 100644 test/wpt/tests/xhr/open-url-redirected-sharedworker-origin.htm create mode 100644 test/wpt/tests/xhr/open-url-redirected-worker-origin.htm create mode 100644 test/wpt/tests/xhr/open-url-worker-origin.htm create mode 100644 test/wpt/tests/xhr/open-url-worker-simple.htm create mode 100644 test/wpt/tests/xhr/open-user-password-non-same-origin.htm create mode 100644 test/wpt/tests/xhr/over-1-meg.any.js create mode 100644 test/wpt/tests/xhr/overridemimetype-blob.html create mode 100644 test/wpt/tests/xhr/overridemimetype-done-state.any.js create mode 100644 test/wpt/tests/xhr/overridemimetype-edge-cases.window.js create mode 100644 test/wpt/tests/xhr/overridemimetype-headers-received-state-force-shiftjis.htm create mode 100644 test/wpt/tests/xhr/overridemimetype-invalid-mime-type.htm create mode 100644 test/wpt/tests/xhr/overridemimetype-loading-state.htm create mode 100644 test/wpt/tests/xhr/overridemimetype-open-state-force-utf-8.htm create mode 100644 test/wpt/tests/xhr/overridemimetype-open-state-force-xml.htm create mode 100644 test/wpt/tests/xhr/overridemimetype-unsent-state-force-shiftjis.any.js create mode 100644 test/wpt/tests/xhr/preserve-ua-header-on-redirect.htm create mode 100644 test/wpt/tests/xhr/progress-events-response-data-gzip.htm create mode 100644 test/wpt/tests/xhr/progressevent-constructor.html create mode 100644 test/wpt/tests/xhr/progressevent-interface.html create mode 100644 test/wpt/tests/xhr/request-content-length.any.js create mode 100644 test/wpt/tests/xhr/resources/accept-language.py create mode 100644 test/wpt/tests/xhr/resources/accept.py create mode 100644 test/wpt/tests/xhr/resources/access-control-allow-lists.py create mode 100644 test/wpt/tests/xhr/resources/access-control-allow-with-body.py create mode 100644 test/wpt/tests/xhr/resources/access-control-auth-basic.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-allow-no-credentials.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-allow-star.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-allow.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-cors-safelisted-request-headers.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-cors-safelisted-response-headers.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-denied.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-options-not-supported.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-preflight-cache-invalidation.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-preflight-cache-timeout.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-preflight-cache.py create mode 100644 test/wpt/tests/xhr/resources/access-control-basic-put-allow.py create mode 100644 test/wpt/tests/xhr/resources/access-control-cookie.py create mode 100644 test/wpt/tests/xhr/resources/access-control-origin-header.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-denied.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-allow-headers-returns-star.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-header-lowercase.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-header-returns-origin.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-header-sorted.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-headers-origin.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-invalid-status.py create mode 100644 test/wpt/tests/xhr/resources/access-control-preflight-request-must-not-contain-cookie.py create mode 100644 test/wpt/tests/xhr/resources/access-control-sandboxed-iframe.html create mode 100644 test/wpt/tests/xhr/resources/auth1/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth10/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth11/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth2/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth2/corsenabled.py create mode 100644 test/wpt/tests/xhr/resources/auth3/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth4/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth5/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth6/auth.py create mode 100644 test/wpt/tests/xhr/resources/auth7/corsenabled.py create mode 100644 test/wpt/tests/xhr/resources/auth8/corsenabled-no-authorize.py create mode 100644 test/wpt/tests/xhr/resources/auth9/auth.py create mode 100644 test/wpt/tests/xhr/resources/authentication.py create mode 100644 test/wpt/tests/xhr/resources/bad-chunk-encoding.py create mode 100644 test/wpt/tests/xhr/resources/base.xml create mode 100644 test/wpt/tests/xhr/resources/chunked.py create mode 100644 test/wpt/tests/xhr/resources/conditional.py create mode 100644 test/wpt/tests/xhr/resources/content.py create mode 100644 test/wpt/tests/xhr/resources/corsenabled.py create mode 100644 test/wpt/tests/xhr/resources/delay.py create mode 100644 test/wpt/tests/xhr/resources/echo-content-cors.py create mode 100644 test/wpt/tests/xhr/resources/echo-content-type.py create mode 100644 test/wpt/tests/xhr/resources/echo-headers.py create mode 100644 test/wpt/tests/xhr/resources/echo-method.py create mode 100644 test/wpt/tests/xhr/resources/empty-div-utf8-html.py create mode 100644 test/wpt/tests/xhr/resources/folder.txt create mode 100644 test/wpt/tests/xhr/resources/form.py create mode 100644 test/wpt/tests/xhr/resources/get-set-cookie.py create mode 100644 test/wpt/tests/xhr/resources/gzip.py create mode 100644 test/wpt/tests/xhr/resources/header-content-length-twice.asis create mode 100644 test/wpt/tests/xhr/resources/header-content-length.asis create mode 100644 test/wpt/tests/xhr/resources/header-user-agent.py create mode 100644 test/wpt/tests/xhr/resources/headers-basic.asis create mode 100644 test/wpt/tests/xhr/resources/headers-double-empty.asis create mode 100644 test/wpt/tests/xhr/resources/headers-some-are-empty.asis create mode 100644 test/wpt/tests/xhr/resources/headers-www-authenticate.asis create mode 100644 test/wpt/tests/xhr/resources/headers.asis create mode 100644 test/wpt/tests/xhr/resources/headers.py create mode 100644 test/wpt/tests/xhr/resources/image.gif create mode 100644 test/wpt/tests/xhr/resources/img-utf8-html.py create mode 100644 test/wpt/tests/xhr/resources/img.jpg create mode 100644 test/wpt/tests/xhr/resources/infinite-redirects.py create mode 100644 test/wpt/tests/xhr/resources/init.htm create mode 100644 test/wpt/tests/xhr/resources/inspect-headers.py create mode 100644 test/wpt/tests/xhr/resources/invalid-utf8-html.py create mode 100644 test/wpt/tests/xhr/resources/last-modified.py create mode 100644 test/wpt/tests/xhr/resources/no-custom-header-on-preflight.py create mode 100644 test/wpt/tests/xhr/resources/nocors/folder.txt create mode 100644 test/wpt/tests/xhr/resources/over-1-meg.txt create mode 100644 test/wpt/tests/xhr/resources/parse-headers.py create mode 100644 test/wpt/tests/xhr/resources/pass.txt create mode 100644 test/wpt/tests/xhr/resources/redirect-cors.py create mode 100644 test/wpt/tests/xhr/resources/redirect.py create mode 100644 test/wpt/tests/xhr/resources/requri.py create mode 100644 test/wpt/tests/xhr/resources/reset-token.py create mode 100644 test/wpt/tests/xhr/resources/responseType-document-in-worker.js create mode 100644 test/wpt/tests/xhr/resources/responseXML-unavailable-in-worker.js create mode 100644 test/wpt/tests/xhr/resources/send-after-setting-document-domain-window-1.htm create mode 100644 test/wpt/tests/xhr/resources/send-after-setting-document-domain-window-2.htm create mode 100644 test/wpt/tests/xhr/resources/send-after-setting-document-domain-window-helper.js create mode 100644 test/wpt/tests/xhr/resources/shift-jis-html.py create mode 100644 test/wpt/tests/xhr/resources/status.py create mode 100644 test/wpt/tests/xhr/resources/top.txt create mode 100644 test/wpt/tests/xhr/resources/trickle.py create mode 100644 test/wpt/tests/xhr/resources/upload.py create mode 100644 test/wpt/tests/xhr/resources/utf16-bom.json create mode 100644 test/wpt/tests/xhr/resources/utf16.txt create mode 100644 test/wpt/tests/xhr/resources/well-formed.xml create mode 100644 test/wpt/tests/xhr/resources/win-1252-html.py create mode 100644 test/wpt/tests/xhr/resources/win-1252-xml.py create mode 100644 test/wpt/tests/xhr/resources/workerxhr-origin-referrer.js create mode 100644 test/wpt/tests/xhr/resources/workerxhr-simple.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-event-order.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-aborted.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-abortedonmain.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-overrides.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-overridesexpires.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-runner.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-simple.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-synconmain.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-synconworker.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout-twice.js create mode 100644 test/wpt/tests/xhr/resources/xmlhttprequest-timeout.js create mode 100644 test/wpt/tests/xhr/resources/zlib.py create mode 100644 test/wpt/tests/xhr/response-body-errors.any.js create mode 100644 test/wpt/tests/xhr/response-data-arraybuffer.htm create mode 100644 test/wpt/tests/xhr/response-data-blob.htm create mode 100644 test/wpt/tests/xhr/response-data-deflate.htm create mode 100644 test/wpt/tests/xhr/response-data-gzip.htm create mode 100644 test/wpt/tests/xhr/response-data-progress.htm create mode 100644 test/wpt/tests/xhr/response-invalid-responsetype.htm create mode 100644 test/wpt/tests/xhr/response-json.htm create mode 100644 test/wpt/tests/xhr/response-method.htm create mode 100644 test/wpt/tests/xhr/responseText-status.html create mode 100644 test/wpt/tests/xhr/responseType-document-in-worker.html create mode 100644 test/wpt/tests/xhr/responseXML-unavailable-in-worker.html create mode 100644 test/wpt/tests/xhr/responsedocument-decoding.htm create mode 100644 test/wpt/tests/xhr/responsetext-decoding.htm create mode 100644 test/wpt/tests/xhr/responsetype.any.js create mode 100644 test/wpt/tests/xhr/responseurl.html create mode 100644 test/wpt/tests/xhr/responsexml-basic.htm create mode 100644 test/wpt/tests/xhr/responsexml-document-properties.htm create mode 100644 test/wpt/tests/xhr/responsexml-get-twice.htm create mode 100644 test/wpt/tests/xhr/responsexml-invalid-type.html create mode 100644 test/wpt/tests/xhr/responsexml-media-type.htm create mode 100644 test/wpt/tests/xhr/responsexml-non-document-types.htm create mode 100644 test/wpt/tests/xhr/responsexml-non-well-formed.htm create mode 100644 test/wpt/tests/xhr/security-consideration.sub.html create mode 100644 test/wpt/tests/xhr/send-accept-language.htm create mode 100644 test/wpt/tests/xhr/send-accept.htm create mode 100644 test/wpt/tests/xhr/send-after-setting-document-domain.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-cors-not-enabled.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-cors.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-repeat-no-args.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-setrequestheader-and-arguments.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-setrequestheader-existing-session.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic-setrequestheader.htm create mode 100644 test/wpt/tests/xhr/send-authentication-basic.htm create mode 100644 test/wpt/tests/xhr/send-authentication-competing-names-passwords.htm create mode 100644 test/wpt/tests/xhr/send-authentication-cors-basic-setrequestheader.htm create mode 100644 test/wpt/tests/xhr/send-authentication-cors-setrequestheader-no-cred.htm create mode 100644 test/wpt/tests/xhr/send-authentication-existing-session-manual.htm create mode 100644 test/wpt/tests/xhr/send-authentication-prompt-2-manual.htm create mode 100644 test/wpt/tests/xhr/send-authentication-prompt-manual.htm create mode 100644 test/wpt/tests/xhr/send-blob-with-no-mime-type.html create mode 100644 test/wpt/tests/xhr/send-conditional-cors.htm create mode 100644 test/wpt/tests/xhr/send-conditional.htm create mode 100644 test/wpt/tests/xhr/send-content-type-charset.htm create mode 100644 test/wpt/tests/xhr/send-content-type-string.htm create mode 100644 test/wpt/tests/xhr/send-data-arraybuffer.any.js create mode 100644 test/wpt/tests/xhr/send-data-arraybufferview.any.js create mode 100644 test/wpt/tests/xhr/send-data-blob.htm create mode 100644 test/wpt/tests/xhr/send-data-es-object.any.js create mode 100644 test/wpt/tests/xhr/send-data-formdata.any.js create mode 100644 test/wpt/tests/xhr/send-data-sharedarraybuffer.any.js create mode 100644 test/wpt/tests/xhr/send-data-string-invalid-unicode.any.js create mode 100644 test/wpt/tests/xhr/send-data-unexpected-tostring.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-basic.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-document-bogus.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-document.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-empty.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-get-head-async.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-get-head.htm create mode 100644 test/wpt/tests/xhr/send-entity-body-none.htm create mode 100644 test/wpt/tests/xhr/send-network-error-async-events.sub.htm create mode 100644 test/wpt/tests/xhr/send-network-error-sync-events.sub.htm create mode 100644 test/wpt/tests/xhr/send-no-response-event-loadend.htm create mode 100644 test/wpt/tests/xhr/send-no-response-event-loadstart.htm create mode 100644 test/wpt/tests/xhr/send-no-response-event-order.htm create mode 100644 test/wpt/tests/xhr/send-non-same-origin.htm create mode 100644 test/wpt/tests/xhr/send-receive-utf16.htm create mode 100644 test/wpt/tests/xhr/send-redirect-bogus-sync.htm create mode 100644 test/wpt/tests/xhr/send-redirect-bogus.htm create mode 100644 test/wpt/tests/xhr/send-redirect-infinite-sync.htm create mode 100644 test/wpt/tests/xhr/send-redirect-infinite.htm create mode 100644 test/wpt/tests/xhr/send-redirect-no-location.htm create mode 100644 test/wpt/tests/xhr/send-redirect-post-upload.htm create mode 100644 test/wpt/tests/xhr/send-redirect-to-cors.htm create mode 100644 test/wpt/tests/xhr/send-redirect-to-non-cors.htm create mode 100644 test/wpt/tests/xhr/send-redirect.htm create mode 100644 test/wpt/tests/xhr/send-response-event-order.htm create mode 100644 test/wpt/tests/xhr/send-response-upload-event-loadend.htm create mode 100644 test/wpt/tests/xhr/send-response-upload-event-loadstart.htm create mode 100644 test/wpt/tests/xhr/send-response-upload-event-progress.htm create mode 100644 test/wpt/tests/xhr/send-send.any.js create mode 100644 test/wpt/tests/xhr/send-sync-blocks-async.htm create mode 100644 test/wpt/tests/xhr/send-sync-no-response-event-load.htm create mode 100644 test/wpt/tests/xhr/send-sync-no-response-event-loadend.htm create mode 100644 test/wpt/tests/xhr/send-sync-no-response-event-order.htm create mode 100644 test/wpt/tests/xhr/send-sync-response-event-order.htm create mode 100644 test/wpt/tests/xhr/send-sync-timeout.htm create mode 100644 test/wpt/tests/xhr/send-timeout-events.htm create mode 100644 test/wpt/tests/xhr/send-usp.any.js create mode 100644 test/wpt/tests/xhr/setrequestheader-after-send.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-allow-empty-value.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-allow-whitespace-in-value.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-before-open.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-bogus-name.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-bogus-value.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-case-insensitive.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-combining.window.js create mode 100644 test/wpt/tests/xhr/setrequestheader-content-type.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-header-allowed.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-header-forbidden.htm create mode 100644 test/wpt/tests/xhr/setrequestheader-open-setrequestheader.htm create mode 100644 test/wpt/tests/xhr/status-async.htm create mode 100644 test/wpt/tests/xhr/status-basic.htm create mode 100644 test/wpt/tests/xhr/status-error.htm create mode 100644 test/wpt/tests/xhr/status.h2.window.js create mode 100644 test/wpt/tests/xhr/sync-no-progress.any.js create mode 100644 test/wpt/tests/xhr/sync-no-timeout.any.js create mode 100644 test/wpt/tests/xhr/sync-xhr-and-window-onload.html create mode 100644 test/wpt/tests/xhr/sync-xhr-supported-by-feature-policy.html create mode 100644 test/wpt/tests/xhr/template-element.html create mode 100644 test/wpt/tests/xhr/thrown-error-in-events.html create mode 100644 test/wpt/tests/xhr/timeout-cors-async.htm create mode 100644 test/wpt/tests/xhr/timeout-multiple-fetches.html create mode 100644 test/wpt/tests/xhr/timeout-sync.htm create mode 100644 test/wpt/tests/xhr/xhr-authorization-redirect.any.js create mode 100644 test/wpt/tests/xhr/xhr-timeout-longtask.any.js create mode 100644 test/wpt/tests/xhr/xmlhttprequest-basic.htm create mode 100644 test/wpt/tests/xhr/xmlhttprequest-eventtarget.htm create mode 100644 test/wpt/tests/xhr/xmlhttprequest-network-error-sync.htm create mode 100644 test/wpt/tests/xhr/xmlhttprequest-network-error.htm create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-block-defer-scripts-subframe.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-block-defer-scripts.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-block-scripts.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-default-feature-policy.sub.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-not-hang-scriptloader-subframe.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-sync-not-hang-scriptloader.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-aborted.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-abortedonmain.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-overrides.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-overridesexpires.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-reused.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-simple.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-synconmain.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-twice.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-aborted.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-overrides.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-overridesexpires.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-simple.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-synconworker.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-timeout-worker-twice.html create mode 100644 test/wpt/tests/xhr/xmlhttprequest-unsent.htm create mode 100644 types/README.md create mode 100644 types/agent.d.ts create mode 100644 types/api.d.ts create mode 100644 types/balanced-pool.d.ts create mode 100644 types/cache.d.ts create mode 100644 types/client.d.ts create mode 100644 types/connector.d.ts create mode 100644 types/content-type.d.ts create mode 100644 types/cookies.d.ts create mode 100644 types/diagnostics-channel.d.ts create mode 100644 types/dispatcher.d.ts create mode 100644 types/errors.d.ts create mode 100644 types/fetch.d.ts create mode 100644 types/file.d.ts create mode 100644 types/filereader.d.ts create mode 100644 types/formdata.d.ts create mode 100644 types/global-dispatcher.d.ts create mode 100644 types/global-origin.d.ts create mode 100644 types/handlers.d.ts create mode 100644 types/header.d.ts create mode 100644 types/index.d.ts create mode 100644 types/interceptors.d.ts create mode 100644 types/mock-agent.d.ts create mode 100644 types/mock-client.d.ts create mode 100644 types/mock-errors.d.ts create mode 100644 types/mock-interceptor.d.ts create mode 100644 types/mock-pool.d.ts create mode 100644 types/patch.d.ts create mode 100644 types/pool-stats.d.ts create mode 100644 types/pool.d.ts create mode 100644 types/proxy-agent.d.ts create mode 100644 types/readable.d.ts create mode 100644 types/retry-handler.d.ts create mode 100644 types/webidl.d.ts create mode 100644 types/websocket.d.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ad0cf5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Ignore everything but the stuff following the `*` with the `!` +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file + +* +!package.json +!lib +!deps +!build diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c7a0d1f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# https://editorconfig.org/ + +root = true + +[*] +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..8ff7029 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,34 @@ +--- +name: Bug Report +about: Report an issue +title: '' +labels: bug +assignees: '' + +--- + +## Bug Description + + + +## Reproducible By + + + +## Expected Behavior + + + +## Logs & Screenshots + + + +## Environment + + + +### Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..0c3a4ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +about: Make a suggestion on a feature or improvement for the project +title: '' +labels: enhancement +assignees: '' + +--- + +## This would solve... + + + +## The implementation should look like... + + + +## I have also considered... + + + +## Additional context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2620ffb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ + + +## This relates to... + + + +## Rationale + + + +## Changes + + + +### Features + + + +### Bug Fixes + + + +### Breaking Changes and Deprecations + + + +## Status + + + + +- [ ] I have read and agreed to the [Developer's Certificate of Origin][cert] +- [ ] Tested +- [ ] Benchmarked (**optional**) +- [ ] Documented +- [ ] Review ready +- [ ] In review +- [ ] Merge ready + +[cert]: https://github.com/nodejs/undici/blob/main/CONTRIBUTING.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..18b9fbf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: /build + schedule: + interval: daily + + - package-ecosystem: pip + directory: /test/wpt/tests/resources/test + schedule: + interval: daily diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..281bdc6 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,43 @@ +name: Benchmarks +on: + - push + - pull_request + +permissions: + contents: read + +jobs: + benchmark_current: + name: benchmark current + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + persist-credentials: false + ref: ${{ github.base_ref }} + - name: Setup Node + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: lts/* + - name: Install Modules + run: npm i + - name: Run Benchmark + run: npm run bench + + benchmark_branch: + name: benchmark branch + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: lts/* + - name: Install Modules + run: npm i + - name: Run Benchmark + run: npm run bench diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3c44e66 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript", "python", "typescript"] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.3.3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.3.3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.3.3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..0e356c7 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,27 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + with: + egress-policy: audit + + - name: 'Checkout Repository' + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - name: 'Dependency Review' + uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..29d7490 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,39 @@ +name: Fuzzing + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + fuzzing: + name: Fuzz + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: lts/* + + - name: Install + run: | + npm install + + - name: Run fuzzing + timeout-minutes: 10 + run: | + npm run fuzz + + - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + if: ${{ failure() }} + with: + name: undici-fuzz-results-${{ github.sha }} + path: | + corpus/ + crash-* + fuzz-results-*.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..2eb2b6b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: Lint +on: [push, pull_request] +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + persist-credentials: false + - uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: lts/* + - run: npm install + - run: npm run lint diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..4c3a77e --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,45 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + + +name: Node CI + +on: + push: + branches: + - current + - next + - 'v*' + pull_request: + +jobs: + build: + name: Test + uses: pkgjs/action/.github/workflows/node-test.yaml@v0.1.7 + with: + runs-on: ubuntu-latest, windows-latest + test-command: npm run coverage:ci + timeout-minutes: 15 + post-test-steps: | + - name: Coverage Report + uses: codecov/codecov-action@v3 + include: | + - runs-on: ubuntu-latest + node-version: 16.8 + exclude: | + - runs-on: windows-latest + node-version: 14 + - runs-on: windows-latest + node-version: 16 + automerge: + if: > + github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' + needs: build + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - uses: fastify/github-action-merge-dependabot@59fc8817458fac20df8884576cfe69dbb77c9a07 # v3.9.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-undici-types.yml b/.github/workflows/publish-undici-types.yml new file mode 100644 index 0000000..3f8fea3 --- /dev/null +++ b/.github/workflows/publish-undici-types.yml @@ -0,0 +1,26 @@ +name: Publish undici-types + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: '16.x' + registry-url: 'https://registry.npmjs.org' + - run: npm install + - run: node scripts/generate-undici-types-package-json.js + - run: npm publish + working-directory: './types' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..f52ad55 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,56 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '16 10 * * 2' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + steps: + - name: "Checkout code" + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd1b69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +# lock files +package-lock.json +yarn.lock + +# IDE files +.idea +.vscode + +*0x +*clinic* + +# Fuzzing +corpus/ +crash-* +fuzz-results-*.json + +# Bundle output +undici-fetch.js +/test/imports/undici-import.js diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..20d0d06 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run lint diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..344e7f6 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +lib/llhttp/llhttp_simd.wasm +lib/llhttp/llhttp.wasm diff --git a/.taprc b/.taprc new file mode 100644 index 0000000..61f7051 --- /dev/null +++ b/.taprc @@ -0,0 +1,7 @@ +ts: false +jsx: false +flow: false +coverage: false +expose-gc: true +timeout: 60 +check-coverage: false diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..27d813e --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +undici.nodejs.org \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cb674bc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,6 @@ +# Code of Conduct + +Undici is committed to upholding the Node.js Code of Conduct. + +The Node.js Code of Conduct document can be found at +https://github.com/nodejs/admin/blob/main/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3a7f3ff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,201 @@ +# Contributing to Undici + +* [Guides](#guides) + * [Update `llhttp`](#update-llhttp) + * [Lint](#lint) + * [Test](#test) + * [Coverage](#coverage) + * [Update `WPTs`](#update-wpts) +* [Developer's Certificate of Origin 1.1](#developers-certificate-of-origin) + * [Moderation Policy](#moderation-policy) + + +## Guides + + +### Update `llhttp` + +The HTTP parser used by `undici` is a WebAssembly build of [`llhttp`](https://github.com/nodejs/llhttp). + +While the project itself provides a way to compile targeting WebAssembly, at the moment we embed the sources +directly and compile the module in `undici`. + +The `deps/llhttp/include` folder contains the C header files, while the `deps/llhttp/src` folder contains +the C source files needed to compile the module. + +The `lib/llhttp` folder contains the `.js` transpiled assets required to implement a parser. + +The following are the steps required to perform an update. + +#### Clone the [llhttp](https://github.com/nodejs/llhttp) project + +```bash +git clone git@github.com:nodejs/llhttp.git + +cd llhttp +``` +#### Checkout a `llhttp` release + +```bash +git checkout +``` + +#### Install the `llhttp` dependencies + +```bash +npm i +``` + +#### Run the wasm build script + +> This requires [docker](https://www.docker.com/) installed on your machine. + +```bash +npm run build-wasm +``` + +#### Copy the sources to `undici` + +```bash +cp build/wasm/*.js /lib/llhttp/ + +cp build/wasm/*.js.map /lib/llhttp/ + +cp build/wasm/*.d.ts /lib/llhttp/ + +cp src/native/api.c src/native/http.c build/c/llhttp.c /deps/llhttp/src/ + +cp src/native/api.h build/llhttp.h /deps/llhttp/include/ +``` + +#### Build the WebAssembly module in `undici` + +> This requires [docker](https://www.docker.com/) installed on your machine. + +```bash +cd + +npm run build:wasm +``` + +#### Commit the contents of lib/llhttp + +Create a commit which includes all of the updated files in lib/llhttp. + + +### Update `WPTs` + +`undici` runs a subset of the [`web-platform-tests`](https://github.com/web-platform-tests/wpt). + +Here are the steps to update them. + +
+Skip the tutorial + +```bash +git clone --depth 1 --single-branch --branch epochs/daily --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git test/wpt/tests +cd test/wpt/tests + +git sparse-checkout add /resources +git sparse-checkout add /interfaces +git sparse-checkout add /common +git sparse-checkout add /fetch +git sparse-checkout add /FileAPI +git sparse-checkout add /xhr +git sparse-checkout add /websockets +git sparse-checkout add /mimesniff +git sparse-checkout add /storage +git sparse-checkout add /service-workers +``` +
+ +#### Sparse-clone the [wpt](https://github.com/web-platform-tests/wpt) repo + +```bash +git clone --depth 1 --single-branch --branch epochs/daily --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git test/wpt/tests + +cd test/wpt/tests + +``` + +#### Checkout the tests + +Only run the commands for the folder(s) you want to update. + +```bash +git sparse-checkout add /fetch +git sparse-checkout add /FileAPI +git sparse-checkout add /xhr +git sparse-checkout add /websockets +git sparse-checkout add /resources +git sparse-checkout add /common + +# etc +``` + +#### Run the tests + +Run the tests to ensure that any new failures are marked as such. + +You can mark tests as failing in their corresponding [status](./test/wpt/status) file. + +```bash +npm run test:wpt +``` + + + +### Lint + +```bash +npm run lint +``` + + +### Test + +```bash +npm run test +``` + + +### Coverage + +```bash +npm run coverage +``` + + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +* (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +* (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +* (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +* (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + + +### Moderation Policy + +The [Node.js Moderation Policy] applies to this project. + +[Node.js Moderation Policy]: +https://github.com/nodejs/admin/blob/main/Moderation-Policy.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..3e88d4b --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,136 @@ +### Undici Working Group + +The Node.js Undici project is governed by a Working Group (WG) +that is responsible for high-level guidance of the project. + +The WG has final authority over this project including: + +* Technical direction +* Project governance and process (including this policy) +* Contribution policy +* GitHub repository hosting +* Conduct guidelines +* Maintaining the list of additional Collaborators + +For the current list of WG members, see the project +[README.md](./README.md#collaborators). + +### Collaborators + +The undici GitHub repository is +maintained by the WG and additional Collaborators who are added by the +WG on an ongoing basis. + +Individuals making significant and valuable contributions are made +Collaborators and given commit-access to the project. These +individuals are identified by the WG and their addition as +Collaborators is discussed during the WG meeting. + +_Note:_ If you make a significant contribution and are not considered +for commit-access log an issue or contact a WG member directly and it +will be brought up in the next WG meeting. + +Modifications of the contents of the undici repository are +made on +a collaborative basis. Anybody with a GitHub account may propose a +modification via pull request and it will be considered by the project +Collaborators. All pull requests must be reviewed and accepted by a +Collaborator with sufficient expertise who is able to take full +responsibility for the change. In the case of pull requests proposed +by an existing Collaborator, an additional Collaborator is required +for sign-off. Consensus should be sought if additional Collaborators +participate and there is disagreement around a particular +modification. See _Consensus Seeking Process_ below for further detail +on the consensus model used for governance. + +Collaborators may opt to elevate significant or controversial +modifications, or modifications that have not found consensus to the +WG for discussion by assigning the ***WG-agenda*** tag to a pull +request or issue. The WG should serve as the final arbiter where +required. + +For the current list of Collaborators, see the project +[README.md](./README.md#collaborators). The list shall be in an +alphabetical order. + +### WG Membership + +WG seats are not time-limited. There is no fixed size of the WG. +However, the expected target is between 6 and 12, to ensure adequate +coverage of important areas of expertise, balanced with the ability to +make decisions efficiently. + +There is no specific set of requirements or qualifications for WG +membership beyond these rules. + +The WG may add additional members to the WG by unanimous consensus. + +A WG member may be removed from the WG by voluntary resignation, or by +unanimous consensus of all other WG members. + +Changes to WG membership should be posted in the agenda, and may be +suggested as any other agenda item (see "WG Meetings" below). + +If an addition or removal is proposed during a meeting, and the full +WG is not in attendance to participate, then the addition or removal +is added to the agenda for the subsequent meeting. This is to ensure +that all members are given the opportunity to participate in all +membership decisions. If a WG member is unable to attend a meeting +where a planned membership decision is being made, then their consent +is assumed. + +No more than 1/3 of the WG members may be affiliated with the same +employer. If removal or resignation of a WG member, or a change of +employment by a WG member, creates a situation where more than 1/3 of +the WG membership shares an employer, then the situation must be +immediately remedied by the resignation or removal of one or more WG +members affiliated with the over-represented employer(s). + +### WG Meetings + +The WG meets occasionally on Zoom. A designated moderator +approved by the WG runs the meeting. Each meeting should be +published to YouTube. + +Items are added to the WG agenda that are considered contentious or +are modifications of governance, contribution policy, WG membership, +or release process. + +The intention of the agenda is not to approve or review all patches; +that should happen continuously on GitHub and be handled by the larger +group of Collaborators. + +Any community member or contributor can ask that something be added to +the next meeting's agenda by logging a GitHub Issue. Any Collaborator, +WG member or the moderator can add the item to the agenda by adding +the ***WG-agenda*** tag to the issue. + +Prior to each WG meeting the moderator will share the Agenda with +members of the WG. WG members can add any items they like to the +agenda at the beginning of each meeting. The moderator and the WG +cannot veto or remove items. + +The WG may invite persons or representatives from certain projects to +participate in a non-voting capacity. + +The moderator is responsible for summarizing the discussion of each +agenda item and sends it as a pull request after the meeting. + +### Consensus Seeking Process + +The WG follows a +[Consensus +Seeking](http://en.wikipedia.org/wiki/Consensus-seeking_decision-making) +decision-making model. + +When an agenda item has appeared to reach a consensus the moderator +will ask "Does anyone object?" as a final call for dissent from the +consensus. + +If an agenda item cannot reach a consensus a WG member can call for +either a closing vote or a vote to table the issue to the next +meeting. The call for a vote must be seconded by a majority of the WG +or else the discussion will continue. Simple majority wins. + +Note that changes to WG membership require a majority consensus. See +"WG Membership" above. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7323bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Matteo Collina and Undici contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..b98d904 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,33 @@ +# Maintainers + +This document details any and all processes relevant to project maintainers. Maintainers should feel empowered to contribute back to this document with any process changes they feel improve the overall experience for themselves and other maintainers. + +## Labels + +Maintainers are encouraged to use the extensive and detailed list of labels for easier repo management. + +* Generally, all issues should be labelled. The most general labels are `bug`, `enhancement`, and `Status: help-wanted`. +* Issues specific to a certain aspect of the project should be labeled using one of the specificity labels listed below. For example, a bug in the `Client` class should have the `Client` and `bug` label assigned. + * Specificity labels: + * `Agent` + * `Client` + * `Docs` + * `Performance` + * `Pool` + * `Tests` + * `Types` +* Any `question` or `usage help` issues should be converted into Q&A Discussions +* `Status:` labels should be added to all open issues indicating their relative development status. + * Status labels: + * `Status: blocked` + * `Status: help-wanted` + * `Status: in-progress` + * `Status: wontfix` +* Issues and/or pull requests with an agreed upon semver status can be assigned the appropriate `semver-` label. + * Semver labels: + * `semver-major` + * `semver-minor` + * `semver-patch` +* Issues with a low-barrier of entry should be assigned the `good first issue` label. +* Do not use the `invalid` label, instead use `bug` or `Status: wontfix`. +* Duplicate issues should initially be assigned the `duplicate` label. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ba8989 --- /dev/null +++ b/README.md @@ -0,0 +1,443 @@ +# undici + +[![Node CI](https://github.com/nodejs/undici/actions/workflows/nodejs.yml/badge.svg)](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/) [![npm version](https://badge.fury.io/js/undici.svg)](https://badge.fury.io/js/undici) [![codecov](https://codecov.io/gh/nodejs/undici/branch/main/graph/badge.svg?token=yZL6LtXkOA)](https://codecov.io/gh/nodejs/undici) + +An HTTP/1.1 client, written from scratch for Node.js. + +> Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici. +It is also a Stranger Things reference. + +Have a question about using Undici? Open a [Q&A Discussion](https://github.com/nodejs/undici/discussions/new) or join our official OpenJS [Slack](https://openjs-foundation.slack.com/archives/C01QF9Q31QD) channel. + +## Install + +``` +npm i undici +``` + +## Benchmarks + +The benchmark is a simple `hello world` [example](benchmarks/benchmark.js) using a +number of unix sockets (connections) with a pipelining depth of 10 running on Node 20.6.0. + +### Connections 1 + + +| Tests | Samples | Result | Tolerance | Difference with slowest | +|---------------------|---------|---------------|-----------|-------------------------| +| http - no keepalive | 15 | 5.32 req/sec | ± 2.61 % | - | +| http - keepalive | 10 | 5.35 req/sec | ± 2.47 % | + 0.44 % | +| undici - fetch | 15 | 41.85 req/sec | ± 2.49 % | + 686.04 % | +| undici - pipeline | 40 | 50.36 req/sec | ± 2.77 % | + 845.92 % | +| undici - stream | 15 | 60.58 req/sec | ± 2.75 % | + 1037.72 % | +| undici - request | 10 | 61.19 req/sec | ± 2.60 % | + 1049.24 % | +| undici - dispatch | 20 | 64.84 req/sec | ± 2.81 % | + 1117.81 % | + + +### Connections 50 + +| Tests | Samples | Result | Tolerance | Difference with slowest | +|---------------------|---------|------------------|-----------|-------------------------| +| undici - fetch | 30 | 2107.19 req/sec | ± 2.69 % | - | +| http - no keepalive | 10 | 2698.90 req/sec | ± 2.68 % | + 28.08 % | +| http - keepalive | 10 | 4639.49 req/sec | ± 2.55 % | + 120.17 % | +| undici - pipeline | 40 | 6123.33 req/sec | ± 2.97 % | + 190.59 % | +| undici - stream | 50 | 9426.51 req/sec | ± 2.92 % | + 347.35 % | +| undici - request | 10 | 10162.88 req/sec | ± 2.13 % | + 382.29 % | +| undici - dispatch | 50 | 11191.11 req/sec | ± 2.98 % | + 431.09 % | + + +## Quick Start + +```js +import { request } from 'undici' + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) +console.log('headers', headers) + +for await (const data of body) { + console.log('data', data) +} + +console.log('trailers', trailers) +``` + +## Body Mixins + +The `body` mixins are the most common way to format the request/response body. Mixins include: + +- [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata) +- [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +- [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) + +Example usage: + +```js +import { request } from 'undici' + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) +console.log('headers', headers) +console.log('data', await body.json()) +console.log('trailers', trailers) +``` + +_Note: Once a mixin has been called then the body cannot be reused, thus calling additional mixins on `.body`, e.g. `.body.json(); .body.text()` will result in an error `TypeError: unusable` being thrown and returned through the `Promise` rejection._ + +Should you need to access the `body` in plain-text after using a mixin, the best practice is to use the `.text()` mixin first and then manually parse the text to the desired format. + +For more information about their behavior, please reference the body mixin from the [Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin). + +## Common API Methods + +This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site. + +### `undici.request([url, options]): Promise` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`RequestOptions`](./docs/api/Dispatcher.md#parameter-requestoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` + * **maxRedirections** `Integer` - Default: `0` + +Returns a promise with the result of the `Dispatcher.request` method. + +Calls `options.dispatcher.request(options)`. + +See [Dispatcher.request](./docs/api/Dispatcher.md#dispatcherrequestoptions-callback) for more details. + +### `undici.stream([url, options, ]factory): Promise` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`StreamOptions`](./docs/api/Dispatcher.md#parameter-streamoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` + * **maxRedirections** `Integer` - Default: `0` +* **factory** `Dispatcher.stream.factory` + +Returns a promise with the result of the `Dispatcher.stream` method. + +Calls `options.dispatcher.stream(options, factory)`. + +See [Dispatcher.stream](docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback) for more details. + +### `undici.pipeline([url, options, ]handler): Duplex` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`PipelineOptions`](docs/api/Dispatcher.md#parameter-pipelineoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` + * **maxRedirections** `Integer` - Default: `0` +* **handler** `Dispatcher.pipeline.handler` + +Returns: `stream.Duplex` + +Calls `options.dispatch.pipeline(options, handler)`. + +See [Dispatcher.pipeline](docs/api/Dispatcher.md#dispatcherpipelineoptions-handler) for more details. + +### `undici.connect([url, options]): Promise` + +Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT). + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`ConnectOptions`](docs/api/Dispatcher.md#parameter-connectoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **maxRedirections** `Integer` - Default: `0` +* **callback** `(err: Error | null, data: ConnectData | null) => void` (optional) + +Returns a promise with the result of the `Dispatcher.connect` method. + +Calls `options.dispatch.connect(options)`. + +See [Dispatcher.connect](docs/api/Dispatcher.md#dispatcherconnectoptions-callback) for more details. + +### `undici.fetch(input[, init]): Promise` + +Implements [fetch](https://fetch.spec.whatwg.org/#fetch-method). + +* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch +* https://fetch.spec.whatwg.org/#fetch-method + +Only supported on Node 16.8+. + +Basic usage example: + +```js +import { fetch } from 'undici' + + +const res = await fetch('https://example.com') +const json = await res.json() +console.log(json) +``` + +You can pass an optional dispatcher to `fetch` as: + +```js +import { fetch, Agent } from 'undici' + +const res = await fetch('https://example.com', { + // Mocks are also supported + dispatcher: new Agent({ + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10 + }) +}) +const json = await res.json() +console.log(json) +``` + +#### `request.body` + +A body can be of the following types: + +- ArrayBuffer +- ArrayBufferView +- AsyncIterables +- Blob +- Iterables +- String +- URLSearchParams +- FormData + +In this implementation of fetch, ```request.body``` now accepts ```Async Iterables```. It is not present in the [Fetch Standard.](https://fetch.spec.whatwg.org) + +```js +import { fetch } from 'undici' + +const data = { + async *[Symbol.asyncIterator]() { + yield 'hello' + yield 'world' + }, +} + +await fetch('https://example.com', { body: data, method: 'POST', duplex: 'half' }) +``` + +#### `request.duplex` + +- half + +In this implementation of fetch, `request.duplex` must be set if `request.body` is `ReadableStream` or `Async Iterables`. And fetch requests are currently always be full duplex. More detail refer to [Fetch Standard.](https://fetch.spec.whatwg.org/#dom-requestinit-duplex) + +#### `response.body` + +Nodejs has two kinds of streams: [web streams](https://nodejs.org/dist/latest-v16.x/docs/api/webstreams.html), which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`. + +```js +import { fetch } from 'undici' +import { Readable } from 'node:stream' + +const response = await fetch('https://example.com') +const readableWebStream = response.body +const readableNodeStream = Readable.fromWeb(readableWebStream) +``` + +#### Specification Compliance + +This section documents parts of the [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does +not support or does not fully implement. + +##### Garbage Collection + +* https://fetch.spec.whatwg.org/#garbage-collection + +The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consuming the response body by relying on +[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources. Undici does not do the same. Therefore, it is important to always either consume or cancel the response body. + +Garbage collection in Node is less aggressive and deterministic +(due to the lack of clear idle periods that browsers have through the rendering refresh rate) +which means that leaving the release of connection resources to the garbage collector can lead +to excessive connection usage, reduced performance (due to less connection re-use), and even +stalls or deadlocks when running out of connections. + +```js +// Do +const headers = await fetch(url) + .then(async res => { + for await (const chunk of res.body) { + // force consumption of body + } + return res.headers + }) + +// Do not +const headers = await fetch(url) + .then(res => res.headers) +``` + +However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details. + +```js +const headers = await fetch(url, { method: 'HEAD' }) + .then(res => res.headers) +``` + +##### Forbidden and Safelisted Header Names + +* https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name +* https://fetch.spec.whatwg.org/#forbidden-header-name +* https://fetch.spec.whatwg.org/#forbidden-response-header-name +* https://github.com/wintercg/fetch/issues/6 + +The [Fetch Standard](https://fetch.spec.whatwg.org) requires implementations to exclude certain headers from requests and responses. In browser environments, some headers are forbidden so the user agent remains in full control over them. In Undici, these constraints are removed to give more control to the user. + +### `undici.upgrade([url, options]): Promise` + +Upgrade to a different protocol. See [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details. + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`UpgradeOptions`](docs/api/Dispatcher.md#parameter-upgradeoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **maxRedirections** `Integer` - Default: `0` +* **callback** `(error: Error | null, data: UpgradeData) => void` (optional) + +Returns a promise with the result of the `Dispatcher.upgrade` method. + +Calls `options.dispatcher.upgrade(options)`. + +See [Dispatcher.upgrade](docs/api/Dispatcher.md#dispatcherupgradeoptions-callback) for more details. + +### `undici.setGlobalDispatcher(dispatcher)` + +* dispatcher `Dispatcher` + +Sets the global dispatcher used by Common API Methods. + +### `undici.getGlobalDispatcher()` + +Gets the global dispatcher used by Common API Methods. + +Returns: `Dispatcher` + +### `undici.setGlobalOrigin(origin)` + +* origin `string | URL | undefined` + +Sets the global origin used in `fetch`. + +If `undefined` is passed, the global origin will be reset. This will cause `Response.redirect`, `new Request()`, and `fetch` to throw an error when a relative path is passed. + +```js +setGlobalOrigin('http://localhost:3000') + +const response = await fetch('/api/ping') + +console.log(response.url) // http://localhost:3000/api/ping +``` + +### `undici.getGlobalOrigin()` + +Gets the global origin used in `fetch`. + +Returns: `URL` + +### `UrlObject` + +* **port** `string | number` (optional) +* **path** `string` (optional) +* **pathname** `string` (optional) +* **hostname** `string` (optional) +* **origin** `string` (optional) +* **protocol** `string` (optional) +* **search** `string` (optional) + +## Specification Compliance + +This section documents parts of the HTTP/1.1 specification that Undici does +not support or does not fully implement. + +### Expect + +Undici does not support the `Expect` request header field. The request +body is always immediately sent and the `100 Continue` response will be +ignored. + +Refs: https://tools.ietf.org/html/rfc7231#section-5.1.1 + +### Pipelining + +Undici will only use pipelining if configured with a `pipelining` factor +greater than `1`. + +Undici always assumes that connections are persistent and will immediately +pipeline requests, without checking whether the connection is persistent. +Hence, automatic fallback to HTTP/1.0 or HTTP/1.1 without pipelining is +not supported. + +Undici will immediately pipeline when retrying requests after a failed +connection. However, Undici will not retry the first remaining requests in +the prior pipeline and instead error the corresponding callback/promise/stream. + +Undici will abort all running requests in the pipeline when any of them are +aborted. + +* Refs: https://tools.ietf.org/html/rfc2616#section-8.1.2.2 +* Refs: https://tools.ietf.org/html/rfc7230#section-6.3.2 + +### Manual Redirect + +Since it is not possible to manually follow an HTTP redirect on the server-side, +Undici returns the actual response instead of an `opaqueredirect` filtered one +when invoked with a `manual` redirect. This aligns `fetch()` with the other +implementations in Deno and Cloudflare Workers. + +Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling + +## Workarounds + +### Network address family autoselection. + +If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record) +first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case +undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`. + +If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version +(18.3.0 and above), you can fix the problem by providing the `autoSelectFamily` option (support by both `undici.request` +and `undici.Agent`) which will enable the family autoselection algorithm when establishing the connection. + +## Collaborators + +* [__Daniele Belardi__](https://github.com/dnlup), +* [__Ethan Arrowood__](https://github.com/ethan-arrowood), +* [__Matteo Collina__](https://github.com/mcollina), +* [__Matthew Aitken__](https://github.com/KhafraDev), +* [__Robert Nagy__](https://github.com/ronag), +* [__Szymon Marczak__](https://github.com/szmarczak), +* [__Tomas Della Vedova__](https://github.com/delvedor), + +### Releasers + +* [__Ethan Arrowood__](https://github.com/ethan-arrowood), +* [__Matteo Collina__](https://github.com/mcollina), +* [__Robert Nagy__](https://github.com/ronag), +* [__Matthew Aitken__](https://github.com/KhafraDev), + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dc5499a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,2 @@ +If you believe you have found a security issue in the software in this +repository, please consult https://github.com/nodejs/node/blob/HEAD/SECURITY.md. diff --git a/benchmarks/benchmark-http2.js b/benchmarks/benchmark-http2.js new file mode 100644 index 0000000..d8555de --- /dev/null +++ b/benchmarks/benchmark-http2.js @@ -0,0 +1,306 @@ +'use strict' + +const { connect } = require('http2') +const { createSecureContext } = require('tls') +const os = require('os') +const path = require('path') +const { readFileSync } = require('fs') +const { table } = require('table') +const { Writable } = require('stream') +const { WritableStream } = require('stream/web') +const { isMainThread } = require('worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const ca = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'ca.pem'), 'utf8') +const servername = 'agent1' + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_THRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `https://localhost:${process.env.PORT}` +} else { + dest.url = 'https://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const httpsBaseOptions = { + ca, + servername, + protocol: 'https:', + hostname: 'localhost', + method: 'GET', + path: '/', + query: { + frappucino: 'muffin', + goat: 'scone', + pond: 'moose', + foo: ['bar', 'baz', 'bal'], + bool: true, + numberKey: 256 + }, + ...dest +} + +const http2ClientOptions = { + secureContext: createSecureContext({ ca }), + servername +} + +const undiciOptions = { + path: '/', + method: 'GET', + headersTimeout, + bodyTimeout +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpsBaseOptions.url, { + allowH2: true, + pipelining, + connections, + connect: { + rejectUnauthorized: false, + ca, + servername + }, + ...dest +}) + +setGlobalDispatcher(new Agent({ + allowH2: true, + pipelining, + connections, + connect: { + rejectUnauthorized: false, + ca, + servername + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +function makeParallelRequests (cb) { + return Promise.all(Array.from(Array(parallelRequests)).map(() => new Promise(cb))) +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return [name, result.size, 'Errored', 'N/A', 'N/A'] + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + return [ + name, + size, + `${((connections * 1e9) / mean).toFixed(2)} req/sec`, + `± ${((standardError / mean) * 100).toFixed(2)} %`, + relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + ] + }) + + console.log(results) + + // Add the header row + rows.unshift(['Tests', 'Samples', 'Result', 'Tolerance', 'Difference with slowest']) + + return table(rows, { + columns: { + 0: { + alignment: 'left' + }, + 1: { + alignment: 'right' + }, + 2: { + alignment: 'right' + }, + 3: { + alignment: 'right' + }, + 4: { + alignment: 'right' + } + }, + drawHorizontalLine: (index, size) => index > 0 && index < size, + border: { + bodyLeft: '│', + bodyRight: '│', + bodyJoin: '│', + joinLeft: '|', + joinRight: '|', + joinJoin: '|' + } + }) +} + +const experiments = { + 'http2 - request' () { + return makeParallelRequests(resolve => { + connect(dest.url, http2ClientOptions, (session) => { + const headers = { + ':path': '/', + ':method': 'GET', + ':scheme': 'https', + ':authority': `localhost:${dest.port}` + } + + const request = session.request(headers) + + request.pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ).on('finish', resolve) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, data => { + return data.body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + try { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('error', (err) => { + console.log('undici - request - dispatcher.request - body - error', err) + }) + .on('finish', () => { + resolve() + }) + }) + } catch (err) { + console.error('undici - request - dispatcher.request - requestCount', err) + } + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url, {}).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + console.log(printResults(results)) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/benchmark-https.js b/benchmarks/benchmark-https.js new file mode 100644 index 0000000..a364f0a --- /dev/null +++ b/benchmarks/benchmark-https.js @@ -0,0 +1,319 @@ +'use strict' + +const https = require('https') +const os = require('os') +const path = require('path') +const { readFileSync } = require('fs') +const { table } = require('table') +const { Writable } = require('stream') +const { WritableStream } = require('stream/web') +const { isMainThread } = require('worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const ca = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'ca.pem'), 'utf8') +const servername = 'agent1' + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_TRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `https://localhost:${process.env.PORT}` +} else { + dest.url = 'https://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const httpsBaseOptions = { + ca, + servername, + protocol: 'https:', + hostname: 'localhost', + method: 'GET', + path: '/', + query: { + frappucino: 'muffin', + goat: 'scone', + pond: 'moose', + foo: ['bar', 'baz', 'bal'], + bool: true, + numberKey: 256 + }, + ...dest +} + +const httpsNoKeepAliveOptions = { + ...httpsBaseOptions, + agent: new https.Agent({ + keepAlive: false, + maxSockets: connections, + // rejectUnauthorized: false, + ca, + servername + }) +} + +const httpsKeepAliveOptions = { + ...httpsBaseOptions, + agent: new https.Agent({ + keepAlive: true, + maxSockets: connections, + // rejectUnauthorized: false, + ca, + servername + }) +} + +const undiciOptions = { + path: '/', + method: 'GET', + headersTimeout, + bodyTimeout +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpsBaseOptions.url, { + pipelining, + connections, + connect: { + // rejectUnauthorized: false, + ca, + servername + }, + ...dest +}) + +setGlobalDispatcher(new Agent({ + pipelining, + connections, + connect: { + // rejectUnauthorized: false, + ca, + servername + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +function makeParallelRequests (cb) { + return Promise.all(Array.from(Array(parallelRequests)).map(() => new Promise(cb))) +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return [name, result.size, 'Errored', 'N/A', 'N/A'] + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + return [ + name, + size, + `${((connections * 1e9) / mean).toFixed(2)} req/sec`, + `± ${((standardError / mean) * 100).toFixed(2)} %`, + relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + ] + }) + + console.log(results) + + // Add the header row + rows.unshift(['Tests', 'Samples', 'Result', 'Tolerance', 'Difference with slowest']) + + return table(rows, { + columns: { + 0: { + alignment: 'left' + }, + 1: { + alignment: 'right' + }, + 2: { + alignment: 'right' + }, + 3: { + alignment: 'right' + }, + 4: { + alignment: 'right' + } + }, + drawHorizontalLine: (index, size) => index > 0 && index < size, + border: { + bodyLeft: '│', + bodyRight: '│', + bodyJoin: '│', + joinLeft: '|', + joinRight: '|', + joinJoin: '|' + } + }) +} + +const experiments = { + 'https - no keepalive' () { + return makeParallelRequests(resolve => { + https.get(httpsNoKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'https - keepalive' () { + return makeParallelRequests(resolve => { + https.get(httpsKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, data => { + return data.body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url, {}).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + console.log(printResults(results)) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js new file mode 100644 index 0000000..5bf3d2e --- /dev/null +++ b/benchmarks/benchmark.js @@ -0,0 +1,300 @@ +'use strict' + +const http = require('http') +const os = require('os') +const path = require('path') +const { table } = require('table') +const { Writable } = require('stream') +const { WritableStream } = require('stream/web') +const { isMainThread } = require('worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_TRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `http://localhost:${process.env.PORT}` +} else { + dest.url = 'http://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const httpBaseOptions = { + protocol: 'http:', + hostname: 'localhost', + method: 'GET', + path: '/', + query: { + frappucino: 'muffin', + goat: 'scone', + pond: 'moose', + foo: ['bar', 'baz', 'bal'], + bool: true, + numberKey: 256 + }, + ...dest +} + +const httpNoKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: false, + maxSockets: connections + }) +} + +const httpKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: true, + maxSockets: connections + }) +} + +const undiciOptions = { + path: '/', + method: 'GET', + headersTimeout, + bodyTimeout +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpBaseOptions.url, { + pipelining, + connections, + ...dest +}) + +setGlobalDispatcher(new Agent({ + pipelining, + connections, + connect: { + rejectUnauthorized: false + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +function makeParallelRequests (cb) { + return Promise.all(Array.from(Array(parallelRequests)).map(() => new Promise(cb))) +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return [name, result.size, 'Errored', 'N/A', 'N/A'] + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + return [ + name, + size, + `${((connections * 1e9) / mean).toFixed(2)} req/sec`, + `± ${((standardError / mean) * 100).toFixed(2)} %`, + relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + ] + }) + + console.log(results) + + // Add the header row + rows.unshift(['Tests', 'Samples', 'Result', 'Tolerance', 'Difference with slowest']) + + return table(rows, { + columns: { + 0: { + alignment: 'left' + }, + 1: { + alignment: 'right' + }, + 2: { + alignment: 'right' + }, + 3: { + alignment: 'right' + }, + 4: { + alignment: 'right' + } + }, + drawHorizontalLine: (index, size) => index > 0 && index < size, + border: { + bodyLeft: '│', + bodyRight: '│', + bodyJoin: '│', + joinLeft: '|', + joinRight: '|', + joinJoin: '|' + } + }) +} + +const experiments = { + 'http - no keepalive' () { + return makeParallelRequests(resolve => { + http.get(httpNoKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'http - keepalive' () { + return makeParallelRequests(resolve => { + http.get(httpKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, data => { + return data.body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + console.log(printResults(results)) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/fetch/bytes-match.mjs b/benchmarks/fetch/bytes-match.mjs new file mode 100644 index 0000000..6c6b263 --- /dev/null +++ b/benchmarks/fetch/bytes-match.mjs @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto' +import { bench, run } from 'mitata' +import { bytesMatch } from '../../lib/web/fetch/util.js' + +const body = Buffer.from('Hello world!') +const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` +const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` + +bench('bytesMatch valid sha256 and base64', () => { + bytesMatch(body, validSha256Base64) +}) +bench('bytesMatch invalid sha256 and base64', () => { + bytesMatch(body, invalidSha256Base64) +}) +bench('bytesMatch valid sha256 and base64url', () => { + bytesMatch(body, validSha256Base64Url) +}) +bench('bytesMatch invalid sha256 and base64url', () => { + bytesMatch(body, invalidSha256Base64Url) +}) + +await run() diff --git a/benchmarks/server-http2.js b/benchmarks/server-http2.js new file mode 100644 index 0000000..0be99cd --- /dev/null +++ b/benchmarks/server-http2.js @@ -0,0 +1,49 @@ +'use strict' + +const { unlinkSync, readFileSync } = require('fs') +const { createSecureServer } = require('http2') +const os = require('os') +const path = require('path') +const cluster = require('cluster') + +const key = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'key.pem'), 'utf8') +const cert = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'cert.pem'), 'utf8') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +const sessionTimeout = 600e3 // 10 minutes + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + const server = createSecureServer( + { + key, + cert, + allowHTTP1: true, + sessionTimeout + }, + (req, res) => { + setTimeout(() => { + res.end(buf) + }, timeout) + } + ) + + server.keepAliveTimeout = 600e3 + + server.listen(port) +} diff --git a/benchmarks/server-https.js b/benchmarks/server-https.js new file mode 100644 index 0000000..f0275d9 --- /dev/null +++ b/benchmarks/server-https.js @@ -0,0 +1,41 @@ +'use strict' + +const { unlinkSync, readFileSync } = require('fs') +const { createServer } = require('https') +const os = require('os') +const path = require('path') +const cluster = require('cluster') + +const key = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'key.pem'), 'utf8') +const cert = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'cert.pem'), 'utf8') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + const server = createServer({ + key, + cert, + keepAliveTimeout: 600e3 + }, (req, res) => { + setTimeout(() => { + res.end(buf) + }, timeout) + }) + + server.listen(port) +} diff --git a/benchmarks/server.js b/benchmarks/server.js new file mode 100644 index 0000000..e1a32e8 --- /dev/null +++ b/benchmarks/server.js @@ -0,0 +1,33 @@ +'use strict' + +const { unlinkSync } = require('fs') +const { createServer } = require('http') +const os = require('os') +const path = require('path') +const cluster = require('cluster') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + const server = createServer((req, res) => { + setTimeout(function () { + res.end(buf) + }, timeout) + }).listen(port) + server.keepAliveTimeout = 600e3 +} diff --git a/benchmarks/wait.js b/benchmarks/wait.js new file mode 100644 index 0000000..771f9f2 --- /dev/null +++ b/benchmarks/wait.js @@ -0,0 +1,22 @@ +'use strict' + +const os = require('os') +const path = require('path') +const waitOn = require('wait-on') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +let resources +if (process.env.PORT) { + resources = [`http-get://localhost:${process.env.PORT}/`] +} else { + resources = [`http-get://unix:${socketPath}:/`] +} + +waitOn({ + resources, + timeout: 5000 +}).catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..5438b73 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine@sha256:4559bc033338938e54d0a3c2f0d7c3ad7d1d13c28c4c405b85c6b3a26f4ce5f7 + +ARG UID=1000 +ARG GID=1000 + +RUN apk add -U clang lld wasi-sdk +RUN mkdir /home/node/undici + +WORKDIR /home/node/undici + +COPY package.json . +COPY build build +COPY deps deps +COPY lib lib + +RUN npm i + +USER node diff --git a/build/wasm.js b/build/wasm.js new file mode 100644 index 0000000..fd90ac2 --- /dev/null +++ b/build/wasm.js @@ -0,0 +1,101 @@ +'use strict' + +const { execSync } = require('child_process') +const { writeFileSync, readFileSync } = require('fs') +const { join, resolve } = require('path') + +const ROOT = resolve(__dirname, '../') +const WASM_SRC = resolve(__dirname, '../deps/llhttp') +const WASM_OUT = resolve(__dirname, '../lib/llhttp') +const DOCKERFILE = resolve(__dirname, './Dockerfile') + +let platform = process.env.WASM_PLATFORM +if (!platform && process.argv[2]) { + platform = execSync('docker info -f "{{.OSType}}/{{.Architecture}}"').toString().trim() +} + +if (process.argv[2] === '--prebuild') { + const cmd = `docker build --platform=${platform.toString().trim()} -t llhttp_wasm_builder -f ${DOCKERFILE} ${ROOT}` + + console.log(`> ${cmd}\n\n`) + execSync(cmd, { stdio: 'inherit' }) + + process.exit(0) +} + +if (process.argv[2] === '--docker') { + let cmd = `docker run --rm -it --platform=${platform.toString().trim()}` + if (process.platform === 'linux') { + cmd += ` --user ${process.getuid()}:${process.getegid()}` + } + + cmd += ` --mount type=bind,source=${ROOT}/lib/llhttp,target=/home/node/undici/lib/llhttp llhttp_wasm_builder node build/wasm.js` + console.log(`> ${cmd}\n\n`) + execSync(cmd, { stdio: 'inherit' }) + process.exit(0) +} + +// Gather information about the tools used for the build +const buildInfo = execSync('apk info -v').toString() +if (!buildInfo.includes('wasi-sdk')) { + console.log('Failed to generate build environment information') + process.exit(-1) +} +writeFileSync(join(WASM_OUT, 'wasm_build_env.txt'), buildInfo) + +// Build wasm binary +execSync(`clang \ + --sysroot=/usr/share/wasi-sysroot \ + -target wasm32-unknown-wasi \ + -Ofast \ + -fno-exceptions \ + -fvisibility=hidden \ + -mexec-model=reactor \ + -Wl,-error-limit=0 \ + -Wl,-O3 \ + -Wl,--lto-O3 \ + -Wl,--strip-all \ + -Wl,--allow-undefined \ + -Wl,--export-dynamic \ + -Wl,--export-table \ + -Wl,--export=malloc \ + -Wl,--export=free \ + -Wl,--no-entry \ + ${join(WASM_SRC, 'src')}/*.c \ + -I${join(WASM_SRC, 'include')} \ + -o ${join(WASM_OUT, 'llhttp.wasm')}`, { stdio: 'inherit' }) + +const base64Wasm = readFileSync(join(WASM_OUT, 'llhttp.wasm')).toString('base64') +writeFileSync( + join(WASM_OUT, 'llhttp-wasm.js'), + `module.exports = '${base64Wasm}'\n` +) + +// Build wasm simd binary +execSync(`clang \ + --sysroot=/usr/share/wasi-sysroot \ + -target wasm32-unknown-wasi \ + -msimd128 \ + -Ofast \ + -fno-exceptions \ + -fvisibility=hidden \ + -mexec-model=reactor \ + -Wl,-error-limit=0 \ + -Wl,-O3 \ + -Wl,--lto-O3 \ + -Wl,--strip-all \ + -Wl,--allow-undefined \ + -Wl,--export-dynamic \ + -Wl,--export-table \ + -Wl,--export=malloc \ + -Wl,--export=free \ + -Wl,--no-entry \ + ${join(WASM_SRC, 'src')}/*.c \ + -I${join(WASM_SRC, 'include')} \ + -o ${join(WASM_OUT, 'llhttp_simd.wasm')}`, { stdio: 'inherit' }) + +const base64WasmSimd = readFileSync(join(WASM_OUT, 'llhttp_simd.wasm')).toString('base64') +writeFileSync( + join(WASM_OUT, 'llhttp_simd-wasm.js'), + `module.exports = '${base64WasmSimd}'\n` +) diff --git a/docs/api/Agent.md b/docs/api/Agent.md new file mode 100644 index 0000000..dd5d99b --- /dev/null +++ b/docs/api/Agent.md @@ -0,0 +1,80 @@ +# Agent + +Extends: `undici.Dispatcher` + +Agent allow dispatching requests against multiple different origins. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new undici.Agent([options])` + +Arguments: + +* **options** `AgentOptions` (optional) + +Returns: `Agent` + +### Parameter: `AgentOptions` + +Extends: [`PoolOptions`](Pool.md#parameter-pooloptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)` +* **maxRedirections** `Integer` - Default: `0`. The number of HTTP redirection to follow unless otherwise specified in `DispatchOptions`. +* **interceptors** `{ Agent: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. + +## Instance Properties + +### `Agent.closed` + +Implements [Client.closed](Client.md#clientclosed) + +### `Agent.destroyed` + +Implements [Client.destroyed](Client.md#clientdestroyed) + +## Instance Methods + +### `Agent.close([callback])` + +Implements [`Dispatcher.close([callback])`](Dispatcher.md#dispatcherclosecallback-promise). + +### `Agent.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Agent.dispatch(options, handler: AgentDispatchOptions)` + +Implements [`Dispatcher.dispatch(options, handler)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +#### Parameter: `AgentDispatchOptions` + +Extends: [`DispatchOptions`](Dispatcher.md#parameter-dispatchoptions) + +* **origin** `string | URL` +* **maxRedirections** `Integer`. + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Agent.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](Dispatcher.md#dispatcherconnectoptions-callback). + +### `Agent.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Agent.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Agent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +### `Agent.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Agent.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](Dispatcher.md#dispatcherupgradeoptions-callback). diff --git a/docs/api/BalancedPool.md b/docs/api/BalancedPool.md new file mode 100644 index 0000000..290c734 --- /dev/null +++ b/docs/api/BalancedPool.md @@ -0,0 +1,99 @@ +# Class: BalancedPool + +Extends: `undici.Dispatcher` + +A pool of [Pool](Pool.md) instances connected to multiple upstreams. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new BalancedPool(upstreams [, options])` + +Arguments: + +* **upstreams** `URL | string | string[]` - It should only include the **protocol, hostname, and port**. +* **options** `BalancedPoolOptions` (optional) + +### Parameter: `BalancedPoolOptions` + +Extends: [`PoolOptions`](Pool.md#parameter-pooloptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)` + +The `PoolOptions` are passed to each of the `Pool` instances being created. +## Instance Properties + +### `BalancedPool.upstreams` + +Returns an array of upstreams that were previously added. + +### `BalancedPool.closed` + +Implements [Client.closed](Client.md#clientclosed) + +### `BalancedPool.destroyed` + +Implements [Client.destroyed](Client.md#clientdestroyed) + +### `Pool.stats` + +Returns [`PoolStats`](PoolStats.md) instance for this pool. + +## Instance Methods + +### `BalancedPool.addUpstream(upstream)` + +Add an upstream. + +Arguments: + +* **upstream** `string` - It should only include the **protocol, hostname, and port**. + +### `BalancedPool.removeUpstream(upstream)` + +Removes an upstream that was previously addded. + +### `BalancedPool.close([callback])` + +Implements [`Dispatcher.close([callback])`](Dispatcher.md#dispatcherclosecallback-promise). + +### `BalancedPool.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `BalancedPool.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](Dispatcher.md#dispatcherconnectoptions-callback). + +### `BalancedPool.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `BalancedPool.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](Dispatcher.md#dispatcherpipelineoptions-handler). + +### `BalancedPool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +### `BalancedPool.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `BalancedPool.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](Dispatcher.md#event-connect). + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](Dispatcher.md#event-disconnect). + +### Event: `'drain'` + +See [Dispatcher Event: `'drain'`](Dispatcher.md#event-drain). diff --git a/docs/api/CacheStorage.md b/docs/api/CacheStorage.md new file mode 100644 index 0000000..08ee99f --- /dev/null +++ b/docs/api/CacheStorage.md @@ -0,0 +1,30 @@ +# CacheStorage + +Undici exposes a W3C spec-compliant implementation of [CacheStorage](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) and [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + +## Opening a Cache + +Undici exports a top-level CacheStorage instance. You can open a new Cache, or duplicate a Cache with an existing name, by using `CacheStorage.prototype.open`. If you open a Cache with the same name as an already-existing Cache, its list of cached Responses will be shared between both instances. + +```mjs +import { caches } from 'undici' + +const cache_1 = await caches.open('v1') +const cache_2 = await caches.open('v1') + +// Although .open() creates a new instance, +assert(cache_1 !== cache_2) +// The same Response is matched in both. +assert.deepStrictEqual(await cache_1.match('/req'), await cache_2.match('/req')) +``` + +## Deleting a Cache + +If a Cache is deleted, the cached Responses/Requests can still be used. + +```mjs +const response = await cache_1.match('/req') +await caches.delete('v1') + +await response.text() // the Response's body +``` diff --git a/docs/api/Client.md b/docs/api/Client.md new file mode 100644 index 0000000..b9e26f0 --- /dev/null +++ b/docs/api/Client.md @@ -0,0 +1,273 @@ +# Class: Client + +Extends: `undici.Dispatcher` + +A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new Client(url[, options])` + +Arguments: + +* **url** `URL | string` - Should only include the **protocol, hostname, and port**. +* **options** `ClientOptions` (optional) + +Returns: `Client` + +### Parameter: `ClientOptions` + +> ⚠️ Warning: The `H2` support is experimental. + +* **bodyTimeout** `number | null` (optional) - Default: `300e3` - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. +* **headersTimeout** `number | null` (optional) - Default: `300e3` - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. +* **keepAliveMaxTimeout** `number | null` (optional) - Default: `600e3` - The maximum allowed `keepAliveTimeout`, in milliseconds, when overridden by *keep-alive* hints from the server. Defaults to 10 minutes. +* **keepAliveTimeout** `number | null` (optional) - Default: `4e3` - The timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by *keep-alive* hints from the server. See [MDN: HTTP - Headers - Keep-Alive directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive#directives) for more details. Defaults to 4 seconds. +* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `1e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 1 second. +* **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. +* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. +* **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. +* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. +* **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. +* **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. +* **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. +* **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. +* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. +* **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. + +#### Parameter: `ConnectOptions` + +Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback). +Furthermore, the following options can be passed: + +* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe. +* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: 100. +* **timeout** `number | null` (optional) - In milliseconds, Default `10e3`. +* **servername** `string | null` (optional) +* **keepAlive** `boolean | null` (optional) - Default: `true` - TCP keep-alive enabled +* **keepAliveInitialDelay** `number | null` (optional) - Default: `60000` - TCP keep-alive interval for the socket in milliseconds + +### Example - Basic Client instantiation + +This will instantiate the undici Client, but it will not connect to the origin until something is queued. Consider using `client.connect` to prematurely connect to the origin, or just call `client.request`. + +```js +'use strict' +import { Client } from 'undici' + +const client = new Client('http://localhost:3000') +``` + +### Example - Custom connector + +This will allow you to perform some additional check on the socket that will be used for the next request. + +```js +'use strict' +import { Client, buildConnector } from 'undici' + +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (/* assertion */) { + socket.destroy() + cb(new Error('kaboom')) + } else { + cb(null, socket) + } + }) + } +}) +``` + +## Instance Methods + +### `Client.close([callback])` + +Implements [`Dispatcher.close([callback])`](Dispatcher.md#dispatcherclosecallback-promise). + +### `Client.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +Waits until socket is closed before invoking the callback (or returning a promise if no callback is provided). + +### `Client.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](Dispatcher.md#dispatcherconnectoptions-callback). + +### `Client.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Client.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Client.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +### `Client.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Client.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Properties + +### `Client.closed` + +* `boolean` + +`true` after `client.close()` has been called. + +### `Client.destroyed` + +* `boolean` + +`true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. + +### `Client.pipelining` + +* `number` + +Property to get and set the pipelining factor. + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](Dispatcher.md#event-connect). + +Parameters: + +* **origin** `URL` +* **targets** `Array` + +Emitted when a socket has been created and connected. The client will connect once `client.size > 0`. + +#### Example - Client connect event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('connect', (origin) => { + console.log(`Connected to ${origin}`) // should print before the request body statement +}) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf-8') + body.on('data', console.log) + client.close() + server.close() +} catch (error) { + console.error(error) + client.close() + server.close() +} +``` + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](Dispatcher.md#event-disconnect). + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +Emitted when socket has disconnected. The error argument of the event is the error which caused the socket to disconnect. The client will reconnect if or once `client.size > 0`. + +#### Example - Client disconnect event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.destroy() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('disconnect', (origin) => { + console.log(`Disconnected from ${origin}`) +}) + +try { + await client.request({ + path: '/', + method: 'GET' + }) +} catch (error) { + console.error(error.message) + client.close() + server.close() +} +``` + +### Event: `'drain'` + +Emitted when pipeline is no longer busy. + +See [Dispatcher Event: `'drain'`](Dispatcher.md#event-drain). + +#### Example - Client drain event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('drain', () => { + console.log('drain event') + client.close() + server.close() +}) + +const requests = [ + client.request({ path: '/', method: 'GET' }), + client.request({ path: '/', method: 'GET' }), + client.request({ path: '/', method: 'GET' }) +] + +await Promise.all(requests) + +console.log('requests completed') +``` + +### Event: `'error'` + +Invoked for users errors such as throwing in the `onError` handler. diff --git a/docs/api/Connector.md b/docs/api/Connector.md new file mode 100644 index 0000000..56821bd --- /dev/null +++ b/docs/api/Connector.md @@ -0,0 +1,115 @@ +# Connector + +Undici creates the underlying socket via the connector builder. +Normally, this happens automatically and you don't need to care about this, +but if you need to perform some additional check over the currently used socket, +this is the right place. + +If you want to create a custom connector, you must import the `buildConnector` utility. + +#### Parameter: `buildConnector.BuildOptions` + +Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback). +Furthermore, the following options can be passed: + +* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe. +* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: `100`. +* **timeout** `number | null` (optional) - In milliseconds. Default `10e3`. +* **servername** `string | null` (optional) + +Once you call `buildConnector`, it will return a connector function, which takes the following parameters. + +#### Parameter: `connector.Options` + +* **hostname** `string` (required) +* **host** `string` (optional) +* **protocol** `string` (required) +* **port** `string` (required) +* **servername** `string` (optional) +* **localAddress** `string | null` (optional) Local address the socket should connect from. +* **httpSocket** `Socket` (optional) Establish secure connection on a given socket rather than creating a new socket. It can only be sent on TLS update. + +### Basic example + +```js +'use strict' + +import { Client, buildConnector } from 'undici' + +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (/* assertion */) { + socket.destroy() + cb(new Error('kaboom')) + } else { + cb(null, socket) + } + }) + } +}) +``` + +### Example: validate the CA fingerprint + +```js +'use strict' + +import { Client, buildConnector } from 'undici' + +const caFingerprint = 'FO:OB:AR' +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) { + socket.destroy() + cb(new Error('Fingerprint does not match or malformed certificate')) + } else { + cb(null, socket) + } + }) + } +}) + +client.request({ + path: '/', + method: 'GET' +}, (err, data) => { + if (err) throw err + + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + console.log(Buffer.concat(bufs).toString('utf8')) + client.close() + }) +}) + +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} +``` diff --git a/docs/api/ContentType.md b/docs/api/ContentType.md new file mode 100644 index 0000000..2bcc9f7 --- /dev/null +++ b/docs/api/ContentType.md @@ -0,0 +1,57 @@ +# MIME Type Parsing + +## `MIMEType` interface + +* **type** `string` +* **subtype** `string` +* **parameters** `Map` +* **essence** `string` + +## `parseMIMEType(input)` + +Implements [parse a MIME type](https://mimesniff.spec.whatwg.org/#parse-a-mime-type). + +Parses a MIME type, returning its type, subtype, and any associated parameters. If the parser can't parse an input it returns the string literal `'failure'`. + +```js +import { parseMIMEType } from 'undici' + +parseMIMEType('text/html; charset=gbk') +// { +// type: 'text', +// subtype: 'html', +// parameters: Map(1) { 'charset' => 'gbk' }, +// essence: 'text/html' +// } +``` + +Arguments: + +* **input** `string` + +Returns: `MIMEType|'failure'` + +## `serializeAMimeType(input)` + +Implements [serialize a MIME type](https://mimesniff.spec.whatwg.org/#serialize-a-mime-type). + +Serializes a MIMEType object. + +```js +import { serializeAMimeType } from 'undici' + +serializeAMimeType({ + type: 'text', + subtype: 'html', + parameters: new Map([['charset', 'gbk']]), + essence: 'text/html' +}) +// text/html;charset=gbk + +``` + +Arguments: + +* **mimeType** `MIMEType` + +Returns: `string` diff --git a/docs/api/Cookies.md b/docs/api/Cookies.md new file mode 100644 index 0000000..0cad379 --- /dev/null +++ b/docs/api/Cookies.md @@ -0,0 +1,101 @@ +# Cookie Handling + +## `Cookie` interface + +* **name** `string` +* **value** `string` +* **expires** `Date|number` (optional) +* **maxAge** `number` (optional) +* **domain** `string` (optional) +* **path** `string` (optional) +* **secure** `boolean` (optional) +* **httpOnly** `boolean` (optional) +* **sameSite** `'String'|'Lax'|'None'` (optional) +* **unparsed** `string[]` (optional) Left over attributes that weren't parsed. + +## `deleteCookie(headers, name[, attributes])` + +Sets the expiry time of the cookie to the unix epoch, causing browsers to delete it when received. + +```js +import { deleteCookie, Headers } from 'undici' + +const headers = new Headers() +deleteCookie(headers, 'name') + +console.log(headers.get('set-cookie')) // name=; Expires=Thu, 01 Jan 1970 00:00:00 GMT +``` + +Arguments: + +* **headers** `Headers` +* **name** `string` +* **attributes** `{ path?: string, domain?: string }` (optional) + +Returns: `void` + +## `getCookies(headers)` + +Parses the `Cookie` header and returns a list of attributes and values. + +```js +import { getCookies, Headers } from 'undici' + +const headers = new Headers({ + cookie: 'get=cookies; and=attributes' +}) + +console.log(getCookies(headers)) // { get: 'cookies', and: 'attributes' } +``` + +Arguments: + +* **headers** `Headers` + +Returns: `Record` + +## `getSetCookies(headers)` + +Parses all `Set-Cookie` headers. + +```js +import { getSetCookies, Headers } from 'undici' + +const headers = new Headers({ 'set-cookie': 'undici=getSetCookies; Secure' }) + +console.log(getSetCookies(headers)) +// [ +// { +// name: 'undici', +// value: 'getSetCookies', +// secure: true +// } +// ] + +``` + +Arguments: + +* **headers** `Headers` + +Returns: `Cookie[]` + +## `setCookie(headers, cookie)` + +Appends a cookie to the `Set-Cookie` header. + +```js +import { setCookie, Headers } from 'undici' + +const headers = new Headers() +setCookie(headers, { name: 'undici', value: 'setCookie' }) + +console.log(headers.get('Set-Cookie')) // undici=setCookie +``` + +Arguments: + +* **headers** `Headers` +* **cookie** `Cookie` + +Returns: `void` diff --git a/docs/api/DiagnosticsChannel.md b/docs/api/DiagnosticsChannel.md new file mode 100644 index 0000000..0aa0b9a --- /dev/null +++ b/docs/api/DiagnosticsChannel.md @@ -0,0 +1,204 @@ +# Diagnostics Channel Support + +Stability: Experimental. + +Undici supports the [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) (currently available only on Node.js v16+). +It is the preferred way to instrument Undici and retrieve internal information. + +The channels available are the following. + +## `undici:request:create` + +This message is published when a new outgoing request is created. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + console.log('origin', request.origin) + console.log('completed', request.completed) + console.log('method', request.method) + console.log('path', request.path) + console.log('headers') // raw text, e.g: 'bar: bar\r\n' + request.addHeader('hello', 'world') + console.log('headers', request.headers) // e.g. 'bar: bar\r\nhello: world\r\n' +}) +``` + +Note: a request is only loosely completed to a given socket. + + +## `undici:request:bodySent` + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) => { + // request is the same object undici:request:create +}) +``` + +## `undici:request:headers` + +This message is published after the response headers have been received, i.e. the response has been completed. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => { + // request is the same object undici:request:create + console.log('statusCode', response.statusCode) + console.log(response.statusText) + // response.headers are buffers. + console.log(response.headers.map((x) => x.toString())) +}) +``` + +## `undici:request:trailers` + +This message is published after the response body and trailers have been received, i.e. the response has been completed. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => { + // request is the same object undici:request:create + console.log('completed', request.completed) + // trailers are buffers. + console.log(trailers.map((x) => x.toString())) +}) +``` + +## `undici:request:error` + +This message is published if the request is going to error, but it has not errored yet. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:error').subscribe(({ request, error }) => { + // request is the same object undici:request:create +}) +``` + +## `undici:client:sendHeaders` + +This message is published right before the first byte of the request is written to the socket. + +*Note*: It will publish the exact headers that will be sent to the server in raw format. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => { + // request is the same object undici:request:create + console.log(`Full headers list ${headers.split('\r\n')}`); +}) +``` + +## `undici:client:beforeConnect` + +This message is published before creating a new connection for **any** request. +You can not assume that this event is related to any specific request. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + // const { host, hostname, protocol, port, servername } = connectParams + // connector is a function that creates the socket +}) +``` + +## `undici:client:connected` + +This message is published after a connection is established. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:connected').subscribe(({ socket, connectParams, connector }) => { + // const { host, hostname, protocol, port, servername } = connectParams + // connector is a function that creates the socket +}) +``` + +## `undici:client:connectError` + +This message is published if it did not succeed to create new connection + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:connectError').subscribe(({ error, socket, connectParams, connector }) => { + // const { host, hostname, protocol, port, servername } = connectParams + // connector is a function that creates the socket + console.log(`Connect failed with ${error.message}`) +}) +``` + +## `undici:websocket:open` + +This message is published after the client has successfully connected to a server. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:open').subscribe(({ address, protocol, extensions }) => { + console.log(address) // address, family, and port + console.log(protocol) // negotiated subprotocols + console.log(extensions) // negotiated extensions +}) +``` + +## `undici:websocket:close` + +This message is published after the connection has closed. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:close').subscribe(({ websocket, code, reason }) => { + console.log(websocket) // the WebSocket object + console.log(code) // the closing status code + console.log(reason) // the closing reason +}) +``` + +## `undici:websocket:socket_error` + +This message is published if the socket experiences an error. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:socket_error').subscribe((error) => { + console.log(error) +}) +``` + +## `undici:websocket:ping` + +This message is published after the client receives a ping frame, if the connection is not closing. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:ping').subscribe(({ payload }) => { + // a Buffer or undefined, containing the optional application data of the frame + console.log(payload) +}) +``` + +## `undici:websocket:pong` + +This message is published after the client receives a pong frame. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:pong').subscribe(({ payload }) => { + // a Buffer or undefined, containing the optional application data of the frame + console.log(payload) +}) +``` diff --git a/docs/api/DispatchInterceptor.md b/docs/api/DispatchInterceptor.md new file mode 100644 index 0000000..7dfc260 --- /dev/null +++ b/docs/api/DispatchInterceptor.md @@ -0,0 +1,60 @@ +# Interface: DispatchInterceptor + +Extends: `Function` + +A function that can be applied to the `Dispatcher.Dispatch` function before it is invoked with a dispatch request. + +This allows one to write logic to intercept both the outgoing request, and the incoming response. + +### Parameter: `Dispatcher.Dispatch` + +The base dispatch function you are decorating. + +### ReturnType: `Dispatcher.Dispatch` + +A dispatch function that has been altered to provide additional logic + +### Basic Example + +Here is an example of an interceptor being used to provide a JWT bearer token + +```js +'use strict' + +const insertHeaderInterceptor = dispatch => { + return function InterceptedDispatch(opts, handler){ + opts.headers.push('Authorization', 'Bearer [Some token]') + return dispatch(opts, handler) + } +} + +const client = new Client('https://localhost:3000', { + interceptors: { Client: [insertHeaderInterceptor] } +}) + +``` + +### Basic Example 2 + +Here is a contrived example of an interceptor stripping the headers from a response. + +```js +'use strict' + +const clearHeadersInterceptor = dispatch => { + const { DecoratorHandler } = require('undici') + class ResultInterceptor extends DecoratorHandler { + onHeaders (statusCode, headers, resume) { + return super.onHeaders(statusCode, [], resume) + } + } + return function InterceptedDispatch(opts, handler){ + return dispatch(opts, new ResultInterceptor(handler)) + } +} + +const client = new Client('https://localhost:3000', { + interceptors: { Client: [clearHeadersInterceptor] } +}) + +``` diff --git a/docs/api/Dispatcher.md b/docs/api/Dispatcher.md new file mode 100644 index 0000000..fd463bf --- /dev/null +++ b/docs/api/Dispatcher.md @@ -0,0 +1,887 @@ +# Dispatcher + +Extends: `events.EventEmitter` + +Dispatcher is the core API used to dispatch requests. + +Requests are not guaranteed to be dispatched in order of invocation. + +## Instance Methods + +### `Dispatcher.close([callback]): Promise` + +Closes the dispatcher and gracefully waits for enqueued requests to complete before resolving. + +Arguments: + +* **callback** `(error: Error | null, data: null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +```js +dispatcher.close() // -> Promise +dispatcher.close(() => {}) // -> void +``` + +#### Example - Request resolves before Client closes + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('undici') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf8') + body.on('data', console.log) +} catch (error) {} + +await client.close() + +console.log('Client closed') +server.close() +``` + +### `Dispatcher.connect(options[, callback])` + +Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT). + +Arguments: + +* **options** `ConnectOptions` +* **callback** `(err: Error | null, data: ConnectData | null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `ConnectOptions` + +* **path** `string` +* **headers** `UndiciHeaders` (optional) - Default: `null` +* **signal** `AbortSignal | events.EventEmitter | null` (optional) - Default: `null` +* **opaque** `unknown` (optional) - This argument parameter is passed through to `ConnectData` + +#### Parameter: `ConnectData` + +* **statusCode** `number` +* **headers** `Record` +* **socket** `stream.Duplex` +* **opaque** `unknown` + +#### Example - Connect request with echo + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + throw Error('should never get here') +}).listen() + +server.on('connect', (req, socket, head) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = head.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) +}) + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { socket } = await client.connect({ + path: '/' + }) + const wanted = 'Body' + let data = '' + socket.on('data', d => { data += d }) + socket.on('end', () => { + console.log(`Data received: ${data.toString()} | Data wanted: ${wanted}`) + client.close() + server.close() + }) + socket.write(wanted) + socket.end() +} catch (error) { } +``` + +### `Dispatcher.destroy([error, callback]): Promise` + +Destroy the dispatcher abruptly with the given error. All the pending and running requests will be asynchronously aborted and error. Since this operation is asynchronously dispatched there might still be some progress on dispatched requests. + +Both arguments are optional; the method can be called in four different ways: + +Arguments: + +* **error** `Error | null` (optional) +* **callback** `(error: Error | null, data: null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +```js +dispatcher.destroy() // -> Promise +dispatcher.destroy(new Error()) // -> Promise +dispatcher.destroy(() => {}) // -> void +dispatcher.destroy(new Error(), () => {}) // -> void +``` + +#### Example - Request is aborted when Client is destroyed + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const request = client.request({ + path: '/', + method: 'GET' + }) + client.destroy() + .then(() => { + console.log('Client destroyed') + server.close() + }) + await request +} catch (error) { + console.error(error) +} +``` + +### `Dispatcher.dispatch(options, handler)` + +This is the low level API which all the preceding APIs are implemented on top of. +This API is expected to evolve through semver-major versions and is less stable than the preceding higher level APIs. +It is primarily intended for library developers who implement higher level APIs on top of this. + +Arguments: + +* **options** `DispatchOptions` +* **handler** `DispatchHandler` + +Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls won't make any progress until the `'drain'` event has been emitted. + +#### Parameter: `DispatchOptions` + +* **origin** `string | URL` +* **path** `string` +* **method** `string` +* **reset** `boolean` (optional) - Default: `false` - If `false`, the request will attempt to create a long-living connection by sending the `connection: keep-alive` header,otherwise will attempt to close it immediately after response by sending `connection: close` within the request and closing the socket afterwards. +* **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null` +* **headers** `UndiciHeaders | string[]` (optional) - Default: `null`. +* **query** `Record | null` (optional) - Default: `null` - Query string params to be embedded in the request URL. Note that both keys and values of query are encoded using `encodeURIComponent`. If for some reason you need to send them unencoded, embed query params into path directly instead. +* **idempotent** `boolean` (optional) - Default: `true` if `method` is `'HEAD'` or `'GET'` - Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline has completed. +* **blocking** `boolean` (optional) - Default: `false` - Whether the response is expected to take a long time and would end up blocking the pipeline. When this is set to `true` further pipelining will be avoided on the same connection until headers have been received. +* **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`. +* **bodyTimeout** `number | null` (optional) - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. +* **headersTimeout** `number | null` (optional) - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. +* **throwOnError** `boolean` (optional) - Default: `false` - Whether Undici should throw an error upon receiving a 4xx or 5xx response from the server. +* **expectContinue** `boolean` (optional) - Default: `false` - For H2, it appends the expect: 100-continue header, and halts the request body until a 100-continue is received from the remote server + +#### Parameter: `DispatchHandler` + +* **onConnect** `(abort: () => void, context: object) => void` - Invoked before request is dispatched on socket. May be invoked multiple times when a request is retried when the request at the head of the pipeline fails. +* **onError** `(error: Error) => void` - Invoked when an error has occurred. May not throw. +* **onUpgrade** `(statusCode: number, headers: Buffer[], socket: Duplex) => void` (optional) - Invoked when request is upgraded. Required if `DispatchOptions.upgrade` is defined or `DispatchOptions.method === 'CONNECT'`. +* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void, statusText: string) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests. +* **onData** `(chunk: Buffer) => boolean` - Invoked when response payload data is received. Not required for `upgrade` requests. +* **onComplete** `(trailers: Buffer[]) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests. +* **onBodySent** `(chunk: string | Buffer | Uint8Array) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream or iterable body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent. + +#### Example 1 - Dispatch GET request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const data = [] + +client.dispatch({ + path: '/', + method: 'GET', + headers: { + 'x-foo': 'bar' + } +}, { + onConnect: () => { + console.log('Connected!') + }, + onError: (error) => { + console.error(error) + }, + onHeaders: (statusCode, headers) => { + console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + }, + onData: (chunk) => { + console.log('onData: chunk received') + data.push(chunk) + }, + onComplete: (trailers) => { + console.log(`onComplete | trailers: ${trailers}`) + const res = Buffer.concat(data).toString('utf8') + console.log(`Data: ${res}`) + client.close() + server.close() + } +}) +``` + +#### Example 2 - Dispatch Upgrade Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end() +}).listen() + +await once(server, 'listening') + +server.on('upgrade', (request, socket, head) => { + console.log('Node.js Server - upgrade event') + socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n') + socket.write('Upgrade: WebSocket\r\n') + socket.write('Connection: Upgrade\r\n') + socket.write('\r\n') + socket.end() +}) + +const client = new Client(`http://localhost:${server.address().port}`) + +client.dispatch({ + path: '/', + method: 'GET', + upgrade: 'websocket' +}, { + onConnect: () => { + console.log('Undici Client - onConnect') + }, + onError: (error) => { + console.log('onError') // shouldn't print + }, + onUpgrade: (statusCode, headers, socket) => { + console.log('Undici Client - onUpgrade') + console.log(`onUpgrade Headers: ${headers}`) + socket.on('data', buffer => { + console.log(buffer.toString('utf8')) + }) + socket.on('end', () => { + client.close() + server.close() + }) + socket.end() + } +}) +``` + +#### Example 3 - Dispatch POST request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + request.on('data', (data) => { + console.log(`Request Data: ${data.toString('utf8')}`) + const body = JSON.parse(data) + body.message = 'World' + response.end(JSON.stringify(body)) + }) +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const data = [] + +client.dispatch({ + path: '/', + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ message: 'Hello' }) +}, { + onConnect: () => { + console.log('Connected!') + }, + onError: (error) => { + console.error(error) + }, + onHeaders: (statusCode, headers) => { + console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + }, + onData: (chunk) => { + console.log('onData: chunk received') + data.push(chunk) + }, + onComplete: (trailers) => { + console.log(`onComplete | trailers: ${trailers}`) + const res = Buffer.concat(data).toString('utf8') + console.log(`Response Data: ${res}`) + client.close() + server.close() + } +}) +``` + +### `Dispatcher.pipeline(options, handler)` + +For easy use with [stream.pipeline](https://nodejs.org/api/stream.html#stream_stream_pipeline_source_transforms_destination_callback). The `handler` argument should return a `Readable` from which the result will be read. Usually it should just return the `body` argument unless some kind of transformation needs to be performed based on e.g. `headers` or `statusCode`. The `handler` should validate the response and save any required state. If there is an error, it should be thrown. The function returns a `Duplex` which writes to the request and reads from the response. + +Arguments: + +* **options** `PipelineOptions` +* **handler** `(data: PipelineHandlerData) => stream.Readable` + +Returns: `stream.Duplex` + +#### Parameter: PipelineOptions + +Extends: [`RequestOptions`](#parameter-requestoptions) + +* **objectMode** `boolean` (optional) - Default: `false` - Set to `true` if the `handler` will return an object stream. + +#### Parameter: PipelineHandlerData + +* **statusCode** `number` +* **headers** `Record` +* **opaque** `unknown` +* **body** `stream.Readable` +* **context** `object` +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +#### Example 1 - Pipeline Echo + +```js +import { Readable, Writable, PassThrough, pipeline } from 'stream' +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + request.pipe(response) +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +let res = '' + +pipeline( + new Readable({ + read () { + this.push(Buffer.from('undici')) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'GET' + }, ({ statusCode, headers, body }) => { + console.log(`response received ${statusCode}`) + console.log('headers', headers) + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, _, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + console.log(`Response pipelined to writable: ${res}`) + callback() + } + }), + error => { + if (error) { + console.error(error) + } + + client.close() + server.close() + } +) +``` + +### `Dispatcher.request(options[, callback])` + +Performs a HTTP request. + +Non-idempotent requests will not be pipelined in order +to avoid indirect failures. + +Idempotent requests will be automatically retried if +they fail due to indirect failure from the request +at the head of the pipeline. This does not apply to +idempotent requests with a stream request body. + +All response bodies must always be fully consumed or destroyed. + +Arguments: + +* **options** `RequestOptions` +* **callback** `(error: Error | null, data: ResponseData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed. + +#### Parameter: `RequestOptions` + +Extends: [`DispatchOptions`](#parameter-dispatchoptions) + +* **opaque** `unknown` (optional) - Default: `null` - Used for passing through context to `ResponseData`. +* **signal** `AbortSignal | events.EventEmitter | null` (optional) - Default: `null`. +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +The `RequestOptions.method` property should not be value `'CONNECT'`. + +#### Parameter: `ResponseData` + +* **statusCode** `number` +* **headers** `Record` - Note that all header keys are lower-cased, e. g. `content-type`. +* **body** `stream.Readable` which also implements [the body mixin from the Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin). +* **trailers** `Record` - This object starts out + as empty and will be mutated to contain trailers after `body` has emitted `'end'`. +* **opaque** `unknown` +* **context** `object` + +`body` contains the following additional [body mixin](https://fetch.spec.whatwg.org/#body-mixin) methods and properties: + +- `text()` +- `json()` +- `arrayBuffer()` +- `body` +- `bodyUsed` + +`body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. + +`body` contains the following additional extensions: + +- `dump({ limit: Integer })`, dump the response by reading up to `limit` bytes without killing the socket (optional) - Default: 262144. + +Note that body will still be a `Readable` even if it is empty, but attempting to deserialize it with `json()` will result in an exception. Recommended way to ensure there is a body to deserialize is to check if status code is not 204, and `content-type` header starts with `application/json`. + +#### Example 1 - Basic GET Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body, headers, statusCode, trailers } = await client.request({ + path: '/', + method: 'GET' + }) + console.log(`response received ${statusCode}`) + console.log('headers', headers) + body.setEncoding('utf8') + body.on('data', console.log) + body.on('end', () => { + console.log('trailers', trailers) + }) + + client.close() + server.close() +} catch (error) { + console.error(error) +} +``` + +#### Example 2 - Aborting a request + +> Node.js v15+ is required to run this example + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) +const abortController = new AbortController() + +try { + client.request({ + path: '/', + method: 'GET', + signal: abortController.signal + }) +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} + +abortController.abort() +``` + +Alternatively, any `EventEmitter` that emits an `'abort'` event may be used as an abort controller: + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import EventEmitter, { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) +const ee = new EventEmitter() + +try { + client.request({ + path: '/', + method: 'GET', + signal: ee + }) +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} + +ee.emit('abort') +``` + +Destroying the request or response body will have the same effect. + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.destroy() +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} +``` + +### `Dispatcher.stream(options, factory[, callback])` + +A faster version of `Dispatcher.request`. This method expects the second argument `factory` to return a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream which the response will be written to. This improves performance by avoiding creating an intermediate [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams) stream when the user expects to directly pipe the response body to a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream. + +As demonstrated in [Example 1 - Basic GET stream request](#example-1---basic-get-stream-request), it is recommended to use the `option.opaque` property to avoid creating a closure for the `factory` method. This pattern works well with Node.js Web Frameworks such as [Fastify](https://fastify.io). See [Example 2 - Stream to Fastify Response](#example-2---stream-to-fastify-response) for more details. + +Arguments: + +* **options** `RequestOptions` +* **factory** `(data: StreamFactoryData) => stream.Writable` +* **callback** `(error: Error | null, data: StreamData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `StreamFactoryData` + +* **statusCode** `number` +* **headers** `Record` +* **opaque** `unknown` +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +#### Parameter: `StreamData` + +* **opaque** `unknown` +* **trailers** `Record` +* **context** `object` + +#### Example 1 - Basic GET stream request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' +import { Writable } from 'stream' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const bufs = [] + +try { + await client.stream({ + path: '/', + method: 'GET', + opaque: { bufs } + }, ({ statusCode, headers, opaque: { bufs } }) => { + console.log(`response received ${statusCode}`) + console.log('headers', headers) + return new Writable({ + write (chunk, encoding, callback) { + bufs.push(chunk) + callback() + } + }) + }) + + console.log(Buffer.concat(bufs).toString('utf-8')) + + client.close() + server.close() +} catch (error) { + console.error(error) +} +``` + +#### Example 2 - Stream to Fastify Response + +In this example, a (fake) request is made to the fastify server using `fastify.inject()`. This request then executes the fastify route handler which makes a subsequent request to the raw Node.js http server using `undici.dispatcher.stream()`. The fastify response is passed to the `opaque` option so that undici can tap into the underlying writable stream using `response.raw`. This methodology demonstrates how one could use undici and fastify together to create fast-as-possible requests from one backend server to another. + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' +import fastify from 'fastify' + +const nodeServer = createServer((request, response) => { + response.end('Hello, World! From Node.js HTTP Server') +}).listen() + +await once(nodeServer, 'listening') + +console.log('Node Server listening') + +const nodeServerUndiciClient = new Client(`http://localhost:${nodeServer.address().port}`) + +const fastifyServer = fastify() + +fastifyServer.route({ + url: '/', + method: 'GET', + handler: (request, response) => { + nodeServerUndiciClient.stream({ + path: '/', + method: 'GET', + opaque: response + }, ({ opaque }) => opaque.raw) + } +}) + +await fastifyServer.listen() + +console.log('Fastify Server listening') + +const fastifyServerUndiciClient = new Client(`http://localhost:${fastifyServer.server.address().port}`) + +try { + const { statusCode, body } = await fastifyServerUndiciClient.request({ + path: '/', + method: 'GET' + }) + + console.log(`response received ${statusCode}`) + body.setEncoding('utf8') + body.on('data', console.log) + + nodeServerUndiciClient.close() + fastifyServerUndiciClient.close() + fastifyServer.close() + nodeServer.close() +} catch (error) { } +``` + +### `Dispatcher.upgrade(options[, callback])` + +Upgrade to a different protocol. Visit [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details. + +Arguments: + +* **options** `UpgradeOptions` + +* **callback** `(error: Error | null, data: UpgradeData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `UpgradeOptions` + +* **path** `string` +* **method** `string` (optional) - Default: `'GET'` +* **headers** `UndiciHeaders` (optional) - Default: `null` +* **protocol** `string` (optional) - Default: `'Websocket'` - A string of comma separated protocols, in descending preference order. +* **signal** `AbortSignal | EventEmitter | null` (optional) - Default: `null` + +#### Parameter: `UpgradeData` + +* **headers** `http.IncomingHeaders` +* **socket** `stream.Duplex` +* **opaque** `unknown` + +#### Example 1 - Basic Upgrade Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.statusCode = 101 + response.setHeader('connection', 'upgrade') + response.setHeader('upgrade', request.headers.upgrade) + response.end() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { headers, socket } = await client.upgrade({ + path: '/', + }) + socket.on('end', () => { + console.log(`upgrade: ${headers.upgrade}`) // upgrade: Websocket + client.close() + server.close() + }) + socket.end() +} catch (error) { + console.error(error) + client.close() + server.close() +} +``` + +## Instance Events + +### Event: `'connect'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` + +### Event: `'disconnect'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +### Event: `'connectionError'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +Emitted when dispatcher fails to connect to +origin. + +### Event: `'drain'` + +Parameters: + +* **origin** `URL` + +Emitted when dispatcher is no longer busy. + +## Parameter: `UndiciHeaders` + +* `Record | string[] | null` + +Header arguments such as `options.headers` in [`Client.dispatch`](Client.md#clientdispatchoptions-handlers) can be specified in two forms; either as an object specified by the `Record` (`IncomingHttpHeaders`) type, or an array of strings. An array representation of a header list must have an even length or an `InvalidArgumentError` will be thrown. + +Keys are lowercase and values are not modified. + +Response headers will derive a `host` from the `url` of the [Client](Client.md#class-client) instance if no `host` header was previously specified. + +### Example 1 - Object + +```js +{ + 'content-length': '123', + 'content-type': 'text/plain', + connection: 'keep-alive', + host: 'mysite.com', + accept: '*/*' +} +``` + +### Example 2 - Array + +```js +[ + 'content-length', '123', + 'content-type', 'text/plain', + 'connection', 'keep-alive', + 'host', 'mysite.com', + 'accept', '*/*' +] +``` diff --git a/docs/api/Errors.md b/docs/api/Errors.md new file mode 100644 index 0000000..917e45d --- /dev/null +++ b/docs/api/Errors.md @@ -0,0 +1,47 @@ +# Errors + +Undici exposes a variety of error objects that you can use to enhance your error handling. +You can find all the error objects inside the `errors` key. + +```js +import { errors } from 'undici' +``` + +| Error | Error Codes | Description | +| ------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------- | +| `UndiciError` | `UND_ERR` | all errors below are extended from `UndiciError`. | +| `ConnectTimeoutError` | `UND_ERR_CONNECT_TIMEOUT` | socket is destroyed due to connect timeout. | +| `HeadersTimeoutError` | `UND_ERR_HEADERS_TIMEOUT` | socket is destroyed due to headers timeout. | +| `HeadersOverflowError` | `UND_ERR_HEADERS_OVERFLOW` | socket is destroyed due to headers' max size being exceeded. | +| `BodyTimeoutError` | `UND_ERR_BODY_TIMEOUT` | socket is destroyed due to body timeout. | +| `ResponseStatusCodeError` | `UND_ERR_RESPONSE_STATUS_CODE` | an error is thrown when `throwOnError` is `true` for status codes >= 400. | +| `InvalidArgumentError` | `UND_ERR_INVALID_ARG` | passed an invalid argument. | +| `InvalidReturnValueError` | `UND_ERR_INVALID_RETURN_VALUE` | returned an invalid value. | +| `RequestAbortedError` | `UND_ERR_ABORTED` | the request has been aborted by the user | +| `ClientDestroyedError` | `UND_ERR_DESTROYED` | trying to use a destroyed client. | +| `ClientClosedError` | `UND_ERR_CLOSED` | trying to use a closed client. | +| `SocketError` | `UND_ERR_SOCKET` | there is an error with the socket. | +| `NotSupportedError` | `UND_ERR_NOT_SUPPORTED` | encountered unsupported functionality. | +| `RequestContentLengthMismatchError` | `UND_ERR_REQ_CONTENT_LENGTH_MISMATCH` | request body does not match content-length header | +| `ResponseContentLengthMismatchError` | `UND_ERR_RES_CONTENT_LENGTH_MISMATCH` | response body does not match content-length header | +| `InformationalError` | `UND_ERR_INFO` | expected error with reason | +| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed | + +### `SocketError` + +The `SocketError` has a `.socket` property which holds socket metadata: + +```ts +interface SocketInfo { + localAddress?: string + localPort?: number + remoteAddress?: string + remotePort?: number + remoteFamily?: string + timeout?: number + bytesWritten?: number + bytesRead?: number +} +``` + +Be aware that in some cases the `.socket` property can be `null`. diff --git a/docs/api/Fetch.md b/docs/api/Fetch.md new file mode 100644 index 0000000..b5a6242 --- /dev/null +++ b/docs/api/Fetch.md @@ -0,0 +1,27 @@ +# Fetch + +Undici exposes a fetch() method starts the process of fetching a resource from the network. + +Documentation and examples can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/fetch). + +## File + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/File) + +In Node versions v18.13.0 and above and v19.2.0 and above, undici will default to using Node's [File](https://nodejs.org/api/buffer.html#class-file) class. In versions where it's not available, it will default to the undici one. + +## FormData + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + +## Response + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Response) + +## Request + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Request) + +## Header + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Headers) diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md new file mode 100644 index 0000000..85ae690 --- /dev/null +++ b/docs/api/MockAgent.md @@ -0,0 +1,540 @@ +# Class: MockAgent + +Extends: `undici.Dispatcher` + +A mocked Agent class that implements the Agent API. It allows one to intercept HTTP requests made through undici and return mocked responses instead. + +## `new MockAgent([options])` + +Arguments: + +* **options** `MockAgentOptions` (optional) - It extends the `Agent` options. + +Returns: `MockAgent` + +### Parameter: `MockAgentOptions` + +Extends: [`AgentOptions`](Agent.md#parameter-agentoptions) + +* **agent** `Agent` (optional) - Default: `new Agent([options])` - a custom agent encapsulated by the MockAgent. + +### Example - Basic MockAgent instantiation + +This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added. + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() +``` + +### Example - Basic MockAgent instantiation with custom agent + +```js +import { Agent, MockAgent } from 'undici' + +const agent = new Agent() + +const mockAgent = new MockAgent({ agent }) +``` + +## Instance Methods + +### `MockAgent.get(origin)` + +This method creates and retrieves MockPool or MockClient instances which can then be used to intercept HTTP requests. If the number of connections on the mock agent is set to 1, a MockClient instance is returned. Otherwise a MockPool instance is returned. + +For subsequent `MockAgent.get` calls on the same origin, the same mock instance will be returned. + +Arguments: + +* **origin** `string | RegExp | (value) => boolean` - a matcher for the pool origin to be retrieved from the MockAgent. + +| Matcher type | Condition to pass | +|:------------:| -------------------------- | +| `string` | Exact match against string | +| `RegExp` | Regex must pass | +| `Function` | Function must return true | + +Returns: `MockClient | MockPool`. + +| `MockAgentOptions` | Mock instance returned | +| -------------------- | ---------------------- | +| `connections === 1` | `MockClient` | +| `connections` > `1` | `MockPool` | + +#### Example - Basic Mocked Request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock agent dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock pool dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockPool }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock client dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +mockClient.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockClient }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked requests with multiple intercepts + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') +mockPool.intercept({ path: '/hello'}).reply(200, 'hello') + +const result1 = await request('http://localhost:3000/foo') + +console.log('response received', result1.statusCode) // response received 200 + +for await (const data of result1.body) { + console.log('data', data.toString('utf8')) // data foo +} + +const result2 = await request('http://localhost:3000/hello') + +console.log('response received', result2.statusCode) // response received 200 + +for await (const data of result2.body) { + console.log('data', data.toString('utf8')) // data hello +} +``` +#### Example - Mock different requests within the same file +```js +const { MockAgent, setGlobalDispatcher } = require('undici'); +const agent = new MockAgent(); +agent.disableNetConnect(); +setGlobalDispatcher(agent); +describe('Test', () => { + it('200', async () => { + const mockAgent = agent.get('http://test.com'); + // your test + }); + it('200', async () => { + const mockAgent = agent.get('http://testing.com'); + // your test + }); +}); +``` + +#### Example - Mocked request with query body, headers and trailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' +}).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } +}) + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo?hello=there&see=ya', { + method: 'POST', + body: 'form1=data1&form2=data2' +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // '{"foo":"bar"}' +} + +console.log('trailers', trailers) // { 'content-md5': 'test' } +``` + +#### Example - Mocked request with origin regex + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get(new RegExp('http://localhost:3000')) +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request with origin function + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get((origin) => origin === 'http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +### `MockAgent.close()` + +Closes the mock agent and waits for registered mock pools and clients to also close before resolving. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +await mockAgent.close() +``` + +### `MockAgent.dispatch(options, handlers)` + +Implements [`Agent.dispatch(options, handlers)`](Agent.md#parameter-agentdispatchoptions). + +### `MockAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockAgent request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await mockAgent.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +### `MockAgent.deactivate()` + +This method disables mocking in MockAgent. + +Returns: `void` + +#### Example - Deactivate Mocking + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.deactivate() +``` + +### `MockAgent.activate()` + +This method enables mocking in a MockAgent instance. When instantiated, a MockAgent is automatically activated. Therefore, this method is only effective after `MockAgent.deactivate` has been called. + +Returns: `void` + +#### Example - Activate Mocking + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.deactivate() +// No mocking will occur + +// Later +mockAgent.activate() +``` + +### `MockAgent.enableNetConnect([host])` + +When requests are not matched in a MockAgent intercept, a real HTTP request is attempted. We can control this further through the use of `enableNetConnect`. This is achieved by defining host matchers so only matching requests will be attempted. + +When using a string, it should only include the **hostname and optionally, the port**. In addition, calling this method multiple times with a string will allow all HTTP requests that match these values. + +Arguments: + +* **host** `string | RegExp | (value) => boolean` - (optional) + +Returns: `void` + +#### Example - Allow all non-matching urls to be dispatched in a real HTTP request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect() + +await request('http://example.com') +// A real request is made +``` + +#### Example - Allow requests matching a host string to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect('example-1.com') +mockAgent.enableNetConnect('example-2.com:8080') + +await request('http://example-1.com') +// A real request is made + +await request('http://example-2.com:8080') +// A real request is made + +await request('http://example-3.com') +// Will throw +``` + +#### Example - Allow requests matching a host regex to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect(new RegExp('example.com')) + +await request('http://example.com') +// A real request is made +``` + +#### Example - Allow requests matching a host function to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect((value) => value === 'example.com') + +await request('http://example.com') +// A real request is made +``` + +### `MockAgent.disableNetConnect()` + +This method causes all requests to throw when requests are not matched in a MockAgent intercept. + +Returns: `void` + +#### Example - Disable all non-matching requests by throwing an error for each + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +mockAgent.disableNetConnect() + +await request('http://example.com') +// Will throw +``` + +### `MockAgent.pendingInterceptors()` + +This method returns any pending interceptors registered on a mock agent. A pending interceptor meets one of the following criteria: + +- Is registered with neither `.times()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` of times. + +Returns: `PendingInterceptor[]` (where `PendingInterceptor` is a `MockDispatch` with an additional `origin: string`) + +#### Example - List all pending inteceptors + +```js +const agent = new MockAgent() +agent.disableNetConnect() + +agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200) + +const pendingInterceptors = agent.pendingInterceptors() +// Returns [ +// { +// timesInvoked: 0, +// times: 1, +// persist: false, +// consumed: false, +// pending: true, +// path: '/', +// method: 'GET', +// body: undefined, +// headers: undefined, +// data: { +// error: null, +// statusCode: 200, +// data: '', +// headers: {}, +// trailers: {} +// }, +// origin: 'https://example.com' +// } +// ] +``` + +### `MockAgent.assertNoPendingInterceptors([options])` + +This method throws if the mock agent has any pending interceptors. A pending interceptor meets one of the following criteria: + +- Is registered with neither `.times()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` of times. + +#### Example - Check that there are no pending interceptors + +```js +const agent = new MockAgent() +agent.disableNetConnect() + +agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200) + +agent.assertNoPendingInterceptors() +// Throws an UndiciError with the following message: +// +// 1 interceptor is pending: +// +// ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +// │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +// ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +// │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +``` diff --git a/docs/api/MockClient.md b/docs/api/MockClient.md new file mode 100644 index 0000000..ac54691 --- /dev/null +++ b/docs/api/MockClient.md @@ -0,0 +1,77 @@ +# Class: MockClient + +Extends: `undici.Client` + +A mock client class that implements the same api as [MockPool](MockPool.md). + +## `new MockClient(origin, [options])` + +Arguments: + +* **origin** `string` - It should only include the **protocol, hostname, and port**. +* **options** `MockClientOptions` - It extends the `Client` options. + +Returns: `MockClient` + +### Parameter: `MockClientOptions` + +Extends: `ClientOptions` + +* **agent** `Agent` - the agent to associate this MockClient with. + +### Example - Basic MockClient instantiation + +We can use MockAgent to instantiate a MockClient ready to be used to intercept specified requests. It will not do anything until registered as the agent to use and any mock request are registered. + +```js +import { MockAgent } from 'undici' + +// Connections must be set to 1 to return a MockClient instance +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +``` + +## Instance Methods + +### `MockClient.intercept(options)` + +Implements: [`MockPool.intercept(options)`](MockPool.md#mockpoolinterceptoptions) + +### `MockClient.close()` + +Implements: [`MockPool.close()`](MockPool.md#mockpoolclose) + +### `MockClient.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `MockClient.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockClient request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +mockClient.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await mockClient.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` diff --git a/docs/api/MockErrors.md b/docs/api/MockErrors.md new file mode 100644 index 0000000..c1aa3db --- /dev/null +++ b/docs/api/MockErrors.md @@ -0,0 +1,12 @@ +# MockErrors + +Undici exposes a variety of mock error objects that you can use to enhance your mock error handling. +You can find all the mock error objects inside the `mockErrors` key. + +```js +import { mockErrors } from 'undici' +``` + +| Mock Error | Mock Error Codes | Description | +| --------------------- | ------------------------------- | ---------------------------------------------------------- | +| `MockNotMatchedError` | `UND_MOCK_ERR_MOCK_NOT_MATCHED` | The request does not match any registered mock dispatches. | diff --git a/docs/api/MockPool.md b/docs/api/MockPool.md new file mode 100644 index 0000000..96a986f --- /dev/null +++ b/docs/api/MockPool.md @@ -0,0 +1,547 @@ +# Class: MockPool + +Extends: `undici.Pool` + +A mock Pool class that implements the Pool API and is used by MockAgent to intercept real requests and return mocked responses. + +## `new MockPool(origin, [options])` + +Arguments: + +* **origin** `string` - It should only include the **protocol, hostname, and port**. +* **options** `MockPoolOptions` - It extends the `Pool` options. + +Returns: `MockPool` + +### Parameter: `MockPoolOptions` + +Extends: `PoolOptions` + +* **agent** `Agent` - the agent to associate this MockPool with. + +### Example - Basic MockPool instantiation + +We can use MockAgent to instantiate a MockPool ready to be used to intercept specified requests. It will not do anything until registered as the agent to use and any mock request are registered. + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +``` + +## Instance Methods + +### `MockPool.intercept(options)` + +This method defines the interception rules for matching against requests for a MockPool or MockPool. We can intercept multiple times on a single instance, but each intercept is only used once. For example if you expect to make 2 requests inside a test, you need to call `intercept()` twice. Assuming you use `disableNetConnect()` you will get `MockNotMatchedError` on the second request when you only call `intercept()` once. + +When defining interception rules, all the rules must pass for a request to be intercepted. If a request is not intercepted, a real request will be attempted. + +| Matcher type | Condition to pass | +|:------------:| -------------------------- | +| `string` | Exact match against string | +| `RegExp` | Regex must pass | +| `Function` | Function must return true | + +Arguments: + +* **options** `MockPoolInterceptOptions` - Interception options. + +Returns: `MockInterceptor` corresponding to the input options. + +### Parameter: `MockPoolInterceptOptions` + +* **path** `string | RegExp | (path: string) => boolean` - a matcher for the HTTP request path. When a `RegExp` or callback is used, it will match against the request path including all query parameters in alphabetical order. When a `string` is provided, the query parameters can be conveniently specified through the `MockPoolInterceptOptions.query` setting. +* **method** `string | RegExp | (method: string) => boolean` - (optional) - a matcher for the HTTP request method. Defaults to `GET`. +* **body** `string | RegExp | (body: string) => boolean` - (optional) - a matcher for the HTTP request body. +* **headers** `Record boolean`> - (optional) - a matcher for the HTTP request headers. To be intercepted, a request must match all defined headers. Extra headers not defined here may (or may not) be included in the request and do not affect the interception in any way. +* **query** `Record | null` - (optional) - a matcher for the HTTP request query string params. Only applies when a `string` was provided for `MockPoolInterceptOptions.path`. + +### Return: `MockInterceptor` + +We can define the behaviour of an intercepted request with the following options. + +* **reply** `(statusCode: number, replyData: string | Buffer | object | MockInterceptor.MockResponseDataHandler, responseOptions?: MockResponseOptions) => MockScope` - define a reply for a matching request. You can define the replyData as a callback to read incoming request data. Default for `responseOptions` is `{}`. +* **reply** `(callback: MockInterceptor.MockReplyOptionsCallback) => MockScope` - define a reply for a matching request, allowing dynamic mocking of all reply options rather than just the data. +* **replyWithError** `(error: Error) => MockScope` - define an error for a matching request to throw. +* **defaultReplyHeaders** `(headers: Record) => MockInterceptor` - define default headers to be included in subsequent replies. These are in addition to headers on a specific reply. +* **defaultReplyTrailers** `(trailers: Record) => MockInterceptor` - define default trailers to be included in subsequent replies. These are in addition to trailers on a specific reply. +* **replyContentLength** `() => MockInterceptor` - define automatically calculated `content-length` headers to be included in subsequent replies. + +The reply data of an intercepted request may either be a string, buffer, or JavaScript object. Objects are converted to JSON while strings and buffers are sent as-is. + +By default, `reply` and `replyWithError` define the behaviour for the first matching request only. Subsequent requests will not be affected (this can be changed using the returned `MockScope`). + +### Parameter: `MockResponseOptions` + +* **headers** `Record` - headers to be included on the mocked reply. +* **trailers** `Record` - trailers to be included on the mocked reply. + +### Return: `MockScope` + +A `MockScope` is associated with a single `MockInterceptor`. With this, we can configure the default behaviour of a intercepted reply. + +* **delay** `(waitInMs: number) => MockScope` - delay the associated reply by a set amount in ms. +* **persist** `() => MockScope` - any matching request will always reply with the defined response indefinitely. +* **times** `(repeatTimes: number) => MockScope` - any matching request will reply with the defined response a fixed amount of times. This is overridden by **persist**. + +#### Example - Basic Mocked Request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +// MockPool +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request using reply data callbacks + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/echo', + method: 'GET', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(200, ({ headers }) => ({ message: headers.get('message') })) + +const { statusCode, body, headers } = await request('http://localhost:3000', { + headers: { + message: 'hello world!' + } +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // { "message":"hello world!" } +} +``` + +#### Example - Mocked request using reply options callback + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/echo', + method: 'GET', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(({ headers }) => ({ statusCode: 200, data: { message: headers.get('message') }}))) + +const { statusCode, body, headers } = await request('http://localhost:3000', { + headers: { + message: 'hello world!' + } +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // { "message":"hello world!" } +} +``` + +#### Example - Basic Mocked requests with multiple intercepts + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo') + +mockPool.intercept({ + path: '/hello', + method: 'GET', +}).reply(200, 'hello') + +const result1 = await request('http://localhost:3000/foo') + +console.log('response received', result1.statusCode) // response received 200 + +for await (const data of result1.body) { + console.log('data', data.toString('utf8')) // data foo +} + +const result2 = await request('http://localhost:3000/hello') + +console.log('response received', result2.statusCode) // response received 200 + +for await (const data of result2.body) { + console.log('data', data.toString('utf8')) // data hello +} +``` + +#### Example - Mocked request with query body, request headers and response headers and trailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } +}) + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo?hello=there&see=ya', { + method: 'POST', + body: 'form1=data1&form2=data2', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } + }) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // '{"foo":"bar"}' +} + +console.log('trailers', trailers) // { 'content-md5': 'test' } +``` + +#### Example - Mocked request using different matchers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: /^GET$/, + body: (value) => value === 'form=data', + headers: { + 'User-Agent': 'undici', + Host: /^example.com$/ + } +}).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { + method: 'GET', + body: 'form=data', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request with reply with a defined error + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyWithError(new Error('kaboom')) + +try { + await request('http://localhost:3000/foo', { + method: 'GET' + }) +} catch (error) { + console.error(error) // Error: kaboom +} +``` + +#### Example - Mocked request with defaultReplyHeaders + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).defaultReplyHeaders({ foo: 'bar' }) + .reply(200, 'foo') + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { foo: 'bar' } +``` + +#### Example - Mocked request with defaultReplyTrailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).defaultReplyTrailers({ foo: 'bar' }) + .reply(200, 'foo') + +const { trailers } = await request('http://localhost:3000/foo') + +console.log('trailers', trailers) // trailers { foo: 'bar' } +``` + +#### Example - Mocked request with automatic content-length calculation + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyContentLength().reply(200, 'foo') + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { 'content-length': '3' } +``` + +#### Example - Mocked request with automatic content-length calculation on an object + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyContentLength().reply(200, { foo: 'bar' }) + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { 'content-length': '13' } +``` + +#### Example - Mocked request with persist enabled + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo').persist() + +const result1 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result2 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +// Etc +``` + +#### Example - Mocked request with times enabled + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo').times(2) + +const result1 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result2 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result3 = await request('http://localhost:3000/foo') +// Will not match and make attempt a real request +``` + +#### Example - Mocked request with path callback + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' +import querystring from 'querystring' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +const matchPath = requestPath => { + const [pathname, search] = requestPath.split('?') + const requestQuery = querystring.parse(search) + + if (!pathname.startsWith('/foo')) { + return false + } + + if (!Object.keys(requestQuery).includes('foo') || requestQuery.foo !== 'bar') { + return false + } + + return true +} + +mockPool.intercept({ + path: matchPath, + method: 'GET' +}).reply(200, 'foo') + +const result = await request('http://localhost:3000/foo?foo=bar') +// Will match and return mocked data +``` + +### `MockPool.close()` + +Closes the mock pool and de-registers from associated MockAgent. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() +const mockPool = mockAgent.get('http://localhost:3000') + +await mockPool.close() +``` + +### `MockPool.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `MockPool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockPool request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ + path: '/foo', + method: 'GET', +}).reply(200, 'foo') + +const { + statusCode, + body +} = await mockPool.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` diff --git a/docs/api/Pool.md b/docs/api/Pool.md new file mode 100644 index 0000000..8fcabac --- /dev/null +++ b/docs/api/Pool.md @@ -0,0 +1,84 @@ +# Class: Pool + +Extends: `undici.Dispatcher` + +A pool of [Client](Client.md) instances connected to the same upstream target. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new Pool(url[, options])` + +Arguments: + +* **url** `URL | string` - It should only include the **protocol, hostname, and port**. +* **options** `PoolOptions` (optional) + +### Parameter: `PoolOptions` + +Extends: [`ClientOptions`](Client.md#parameter-clientoptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Client(origin, opts)` +* **connections** `number | null` (optional) - Default: `null` - The number of `Client` instances to create. When set to `null`, the `Pool` instance will create an unlimited amount of `Client` instances. +* **interceptors** `{ Pool: DispatchInterceptor[] } }` - Default: `{ Pool: [] }` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). + +## Instance Properties + +### `Pool.closed` + +Implements [Client.closed](Client.md#clientclosed) + +### `Pool.destroyed` + +Implements [Client.destroyed](Client.md#clientdestroyed) + +### `Pool.stats` + +Returns [`PoolStats`](PoolStats.md) instance for this pool. + +## Instance Methods + +### `Pool.close([callback])` + +Implements [`Dispatcher.close([callback])`](Dispatcher.md#dispatcherclosecallback-promise). + +### `Pool.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Pool.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](Dispatcher.md#dispatcherconnectoptions-callback). + +### `Pool.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Pool.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Pool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +### `Pool.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Pool.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](Dispatcher.md#event-connect). + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](Dispatcher.md#event-disconnect). + +### Event: `'drain'` + +See [Dispatcher Event: `'drain'`](Dispatcher.md#event-drain). diff --git a/docs/api/PoolStats.md b/docs/api/PoolStats.md new file mode 100644 index 0000000..16b6dc2 --- /dev/null +++ b/docs/api/PoolStats.md @@ -0,0 +1,35 @@ +# Class: PoolStats + +Aggregate stats for a [Pool](Pool.md) or [BalancedPool](BalancedPool.md). + +## `new PoolStats(pool)` + +Arguments: + +* **pool** `Pool` - Pool or BalancedPool from which to return stats. + +## Instance Properties + +### `PoolStats.connected` + +Number of open socket connections in this pool. + +### `PoolStats.free` + +Number of open socket connections in this pool that do not have an active request. + +### `PoolStats.pending` + +Number of pending requests across all clients in this pool. + +### `PoolStats.queued` + +Number of queued requests across all clients in this pool. + +### `PoolStats.running` + +Number of currently active requests across all clients in this pool. + +### `PoolStats.size` + +Number of active, pending, or queued requests across all clients in this pool. diff --git a/docs/api/ProxyAgent.md b/docs/api/ProxyAgent.md new file mode 100644 index 0000000..cebfe68 --- /dev/null +++ b/docs/api/ProxyAgent.md @@ -0,0 +1,126 @@ +# Class: ProxyAgent + +Extends: `undici.Dispatcher` + +A Proxy Agent class that implements the Agent API. It allows the connection through proxy in a simple way. + +## `new ProxyAgent([options])` + +Arguments: + +* **options** `ProxyAgentOptions` (required) - It extends the `Agent` options. + +Returns: `ProxyAgent` + +### Parameter: `ProxyAgentOptions` + +Extends: [`AgentOptions`](Agent.md#parameter-agentoptions) + +* **uri** `string` (required) - It can be passed either by a string or a object containing `uri` as string. +* **token** `string` (optional) - It can be passed by a string of token for authentication. +* **auth** `string` (**deprecated**) - Use token. +* **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)` +* **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback). +* **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback). + +Examples: + +```js +import { ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +// or +const proxyAgent = new ProxyAgent({ uri: 'my.proxy.server' }) +``` + +#### Example - Basic ProxyAgent instantiation + +This will instantiate the ProxyAgent. It will not do anything until registered as the agent to use with requests. + +```js +import { ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +``` + +#### Example - Basic Proxy Request with global agent dispatcher + +```js +import { setGlobalDispatcher, request, ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +setGlobalDispatcher(proxyAgent) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with local agent dispatcher + +```js +import { ProxyAgent, request } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: proxyAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with authentication + +```js +import { setGlobalDispatcher, request, ProxyAgent } from 'undici'; + +const proxyAgent = new ProxyAgent({ + uri: 'my.proxy.server', + // token: 'Bearer xxxx' + token: `Basic ${Buffer.from('username:password').toString('base64')}` +}); +setGlobalDispatcher(proxyAgent); + +const { statusCode, body } = await request('http://localhost:3000/foo'); + +console.log('response received', statusCode); // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')); // data foo +} +``` + +### `ProxyAgent.close()` + +Closes the proxy agent and waits for registered pools and clients to also close before resolving. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { ProxyAgent, setGlobalDispatcher } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +setGlobalDispatcher(proxyAgent) + +await proxyAgent.close() +``` + +### `ProxyAgent.dispatch(options, handlers)` + +Implements [`Agent.dispatch(options, handlers)`](Agent.md#parameter-agentdispatchoptions). + +### `ProxyAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). diff --git a/docs/api/RetryHandler.md b/docs/api/RetryHandler.md new file mode 100644 index 0000000..2323ce4 --- /dev/null +++ b/docs/api/RetryHandler.md @@ -0,0 +1,108 @@ +# Class: RetryHandler + +Extends: `undici.DispatcherHandlers` + +A handler class that implements the retry logic for a request. + +## `new RetryHandler(dispatchOptions, retryHandlers, [retryOptions])` + +Arguments: + +- **options** `Dispatch.DispatchOptions & RetryOptions` (required) - It is an intersection of `Dispatcher.DispatchOptions` and `RetryOptions`. +- **retryHandlers** `RetryHandlers` (required) - Object containing the `dispatch` to be used on every retry, and `handler` for handling the `dispatch` lifecycle. + +Returns: `retryHandler` + +### Parameter: `Dispatch.DispatchOptions & RetryOptions` + +Extends: [`Dispatch.DispatchOptions`](Dispatcher.md#parameter-dispatchoptions). + +#### `RetryOptions` + +- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed. +- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5` +- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds) +- **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second) +- **timeoutFactor** `number` (optional) - Factor to multiply the timeout by for each retry attempt. Default: `2` +- **retryAfter** `boolean` (optional) - It enables automatic retry after the `Retry-After` header is received. Default: `true` +- +- **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']` +- **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]` +- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', + +**`RetryContext`** + +- `state`: `RetryState` - Current retry state. It can be mutated. +- `opts`: `Dispatch.DispatchOptions & RetryOptions` - Options passed to the retry handler. + +### Parameter `RetryHandlers` + +- **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandlers) => Promise` (required) - Dispatch function to be called after every retry. +- **handler** Extends [`Dispatch.DispatchHandlers`](Dispatcher.md#dispatcherdispatchoptions-handler) (required) - Handler function to be called after the request is successful or the retries are exhausted. + +Examples: + +```js +const client = new Client(`http://localhost:${server.address().port}`); +const chunks = []; +const handler = new RetryHandler( + { + ...dispatchOptions, + retryOptions: { + // custom retry function + retry: function (err, state, callback) { + counter++; + + if (err.code && err.code === "UND_ERR_DESTROYED") { + callback(err); + return; + } + + if (err.statusCode === 206) { + callback(err); + return; + } + + setTimeout(() => callback(null), 1000); + }, + }, + }, + { + dispatch: (...args) => { + return client.dispatch(...args); + }, + handler: { + onConnect() {}, + onBodySent() {}, + onHeaders(status, _rawHeaders, resume, _statusMessage) { + // do something with headers + }, + onData(chunk) { + chunks.push(chunk); + return true; + }, + onComplete() {}, + onError() { + // handle error properly + }, + }, + } +); +``` + +#### Example - Basic RetryHandler with defaults + +```js +const client = new Client(`http://localhost:${server.address().port}`); +const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect() {}, + onBodySent() {}, + onHeaders(status, _rawHeaders, resume, _statusMessage) {}, + onData(chunk) {}, + onComplete() {}, + onError(err) {}, + }, +}); +``` diff --git a/docs/api/WebSocket.md b/docs/api/WebSocket.md new file mode 100644 index 0000000..9d374f4 --- /dev/null +++ b/docs/api/WebSocket.md @@ -0,0 +1,43 @@ +# Class: WebSocket + +> ⚠️ Warning: the WebSocket API is experimental. + +Extends: [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) + +The WebSocket object provides a way to manage a WebSocket connection to a server, allowing bidirectional communication. The API follows the [WebSocket spec](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) and [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). + +## `new WebSocket(url[, protocol])` + +Arguments: + +* **url** `URL | string` - The url's protocol *must* be `ws` or `wss`. +* **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](./Dispatcher.md). + +### Example: + +This example will not work in browsers or other platforms that don't allow passing an object. + +```mjs +import { WebSocket, ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') + +const ws = new WebSocket('wss://echo.websocket.events', { + dispatcher: proxyAgent, + protocols: ['echo', 'chat'] +}) +``` + +If you do not need a custom Dispatcher, it's recommended to use the following pattern: + +```mjs +import { WebSocket } from 'undici' + +const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat']) +``` + +## Read More + +- [MDN - WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +- [The WebSocket Specification](https://www.rfc-editor.org/rfc/rfc6455) +- [The WHATWG WebSocket Specification](https://websockets.spec.whatwg.org/) diff --git a/docs/api/api-lifecycle.md b/docs/api/api-lifecycle.md new file mode 100644 index 0000000..d158126 --- /dev/null +++ b/docs/api/api-lifecycle.md @@ -0,0 +1,62 @@ +# Client Lifecycle + +An Undici [Client](Client.md) can be best described as a state machine. The following list is a summary of the various state transitions the `Client` will go through in its lifecycle. This document also contains detailed breakdowns of each state. + +> This diagram is not a perfect representation of the undici Client. Since the Client class is not actually implemented as a state-machine, actual execution may deviate slightly from what is described below. Consider this as a general resource for understanding the inner workings of the Undici client rather than some kind of formal specification. + +## State Transition Overview + +* A `Client` begins in the **idle** state with no socket connection and no requests in queue. + * The *connect* event transitions the `Client` to the **pending** state where requests can be queued prior to processing. + * The *close* and *destroy* events transition the `Client` to the **destroyed** state. Since there are no requests in the queue, the *close* event immediately transitions to the **destroyed** state. +* The **pending** state indicates the underlying socket connection has been successfully established and requests are queueing. + * The *process* event transitions the `Client` to the **processing** state where requests are processed. + * If requests are queued, the *close* event transitions to the **processing** state; otherwise, it transitions to the **destroyed** state. + * The *destroy* event transitions to the **destroyed** state. +* The **processing** state initializes to the **processing.running** state. + * If the current request requires draining, the *needDrain* event transitions the `Client` into the **processing.busy** state which will return to the **processing.running** state with the *drainComplete* event. + * After all queued requests are completed, the *keepalive* event transitions the `Client` back to the **pending** state. If no requests are queued during the timeout, the **close** event transitions the `Client` to the **destroyed** state. + * If the *close* event is fired while the `Client` still has queued requests, the `Client` transitions to the **process.closing** state where it will complete all existing requests before firing the *done* event. + * The *done* event gracefully transitions the `Client` to the **destroyed** state. + * At any point in time, the *destroy* event will transition the `Client` from the **processing** state to the **destroyed** state, destroying any queued requests. +* The **destroyed** state is a final state and the `Client` is no longer functional. + +![A state diagram representing an Undici Client instance](../assets/lifecycle-diagram.png) + +> The diagram was generated using Mermaid.js Live Editor. Modify the state diagram [here](https://mermaid-js.github.io/mermaid-live-editor/#/edit/eyJjb2RlIjoic3RhdGVEaWFncmFtLXYyXG4gICAgWypdIC0tPiBpZGxlXG4gICAgaWRsZSAtLT4gcGVuZGluZyA6IGNvbm5lY3RcbiAgICBpZGxlIC0tPiBkZXN0cm95ZWQgOiBkZXN0cm95L2Nsb3NlXG4gICAgXG4gICAgcGVuZGluZyAtLT4gaWRsZSA6IHRpbWVvdXRcbiAgICBwZW5kaW5nIC0tPiBkZXN0cm95ZWQgOiBkZXN0cm95XG5cbiAgICBzdGF0ZSBjbG9zZV9mb3JrIDw8Zm9yaz4-XG4gICAgcGVuZGluZyAtLT4gY2xvc2VfZm9yayA6IGNsb3NlXG4gICAgY2xvc2VfZm9yayAtLT4gcHJvY2Vzc2luZ1xuICAgIGNsb3NlX2ZvcmsgLS0-IGRlc3Ryb3llZFxuXG4gICAgcGVuZGluZyAtLT4gcHJvY2Vzc2luZyA6IHByb2Nlc3NcblxuICAgIHByb2Nlc3NpbmcgLS0-IHBlbmRpbmcgOiBrZWVwYWxpdmVcbiAgICBwcm9jZXNzaW5nIC0tPiBkZXN0cm95ZWQgOiBkb25lXG4gICAgcHJvY2Vzc2luZyAtLT4gZGVzdHJveWVkIDogZGVzdHJveVxuXG4gICAgc3RhdGUgcHJvY2Vzc2luZyB7XG4gICAgICAgIHJ1bm5pbmcgLS0-IGJ1c3kgOiBuZWVkRHJhaW5cbiAgICAgICAgYnVzeSAtLT4gcnVubmluZyA6IGRyYWluQ29tcGxldGVcbiAgICAgICAgcnVubmluZyAtLT4gWypdIDoga2VlcGFsaXZlXG4gICAgICAgIHJ1bm5pbmcgLS0-IGNsb3NpbmcgOiBjbG9zZVxuICAgICAgICBjbG9zaW5nIC0tPiBbKl0gOiBkb25lXG4gICAgICAgIFsqXSAtLT4gcnVubmluZ1xuICAgIH1cbiAgICAiLCJtZXJtYWlkIjp7InRoZW1lIjoiYmFzZSJ9LCJ1cGRhdGVFZGl0b3IiOmZhbHNlfQ) + +## State details + +### idle + +The **idle** state is the initial state of a `Client` instance. While an `origin` is required for instantiating a `Client` instance, the underlying socket connection will not be established until a request is queued using [`Client.dispatch()`](Client.md#clientdispatchoptions-handlers). By calling `Client.dispatch()` directly or using one of the multiple implementations ([`Client.connect()`](Client.md#clientconnectoptions-callback), [`Client.pipeline()`](Client.md#clientpipelineoptions-handler), [`Client.request()`](Client.md#clientrequestoptions-callback), [`Client.stream()`](Client.md#clientstreamoptions-factory-callback), and [`Client.upgrade()`](Client.md#clientupgradeoptions-callback)), the `Client` instance will transition from **idle** to [**pending**](#pending) and then most likely directly to [**processing**](#processing). + +Calling [`Client.close()`](Client.md#clientclosecallback) or [`Client.destroy()`](Client.md#clientdestroyerror-callback) transitions directly to the [**destroyed**](#destroyed) state since the `Client` instance will have no queued requests in this state. + +### pending + +The **pending** state signifies a non-processing `Client`. Upon entering this state, the `Client` establishes a socket connection and emits the [`'connect'`](Client.md#event-connect) event signalling a connection was successfully established with the `origin` provided during `Client` instantiation. The internal queue is initially empty, and requests can start queueing. + +Calling [`Client.close()`](Client.md#clientclosecallback) with queued requests, transitions the `Client` to the [**processing**](#processing) state. Without queued requests, it transitions to the [**destroyed**](#destroyed) state. + +Calling [`Client.destroy()`](Client.md#clientdestroyerror-callback) transitions directly to the [**destroyed**](#destroyed) state regardless of existing requests. + +### processing + +The **processing** state is a state machine within itself. It initializes to the [**processing.running**](#running) state. The [`Client.dispatch()`](Client.md#clientdispatchoptions-handlers), [`Client.close()`](Client.md#clientclosecallback), and [`Client.destroy()`](Client.md#clientdestroyerror-callback) can be called at any time while the `Client` is in this state. `Client.dispatch()` will add more requests to the queue while existing requests continue to be processed. `Client.close()` will transition to the [**processing.closing**](#closing) state. And `Client.destroy()` will transition to [**destroyed**](#destroyed). + +#### running + +In the **processing.running** sub-state, queued requests are being processed in a FIFO order. If a request body requires draining, the *needDrain* event transitions to the [**processing.busy**](#busy) sub-state. The *close* event transitions the Client to the [**process.closing**](#closing) sub-state. If all queued requests are processed and neither [`Client.close()`](Client.md#clientclosecallback) nor [`Client.destroy()`](Client.md#clientdestroyerror-callback) are called, then the [**processing**](#processing) machine will trigger a *keepalive* event transitioning the `Client` back to the [**pending**](#pending) state. During this time, the `Client` is waiting for the socket connection to timeout, and once it does, it triggers the *timeout* event and transitions to the [**idle**](#idle) state. + +#### busy + +This sub-state is only entered when a request body is an instance of [Stream](https://nodejs.org/api/stream.html) and requires draining. The `Client` cannot process additional requests while in this state and must wait until the currently processing request body is completely drained before transitioning back to [**processing.running**](#running). + +#### closing + +This sub-state is only entered when a `Client` instance has queued requests and the [`Client.close()`](Client.md#clientclosecallback) method is called. In this state, the `Client` instance continues to process requests as usual, with the one exception that no additional requests can be queued. Once all of the queued requests are processed, the `Client` will trigger the *done* event gracefully entering the [**destroyed**](#destroyed) state without an error. + +### destroyed + +The **destroyed** state is a final state for the `Client` instance. Once in this state, a `Client` is nonfunctional. Calling any other `Client` methods will result in an `ClientDestroyedError`. diff --git a/docs/assets/lifecycle-diagram.png b/docs/assets/lifecycle-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..4ef17b5a71454bdd5d0920bdbc162d24347f7440 GIT binary patch literal 47090 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 Mqas}-Y2^R^0VGg_TmS$7 literal 0 HcmV?d00001 diff --git a/docs/best-practices/client-certificate.md b/docs/best-practices/client-certificate.md new file mode 100644 index 0000000..4fc84ec --- /dev/null +++ b/docs/best-practices/client-certificate.md @@ -0,0 +1,64 @@ +# Client certificate + +Client certificate authentication can be configured with the `Client`, the required options are passed along through the `connect` option. + +The client certificates must be signed by a trusted CA. The Node.js default is to trust the well-known CAs curated by Mozilla. + +Setting the server option `requestCert: true` tells the server to request the client certificate. + +The server option `rejectUnauthorized: false` allows us to handle any invalid certificate errors in client code. The `authorized` property on the socket of the incoming request will show if the client certificate was valid. The `authorizationError` property will give the reason if the certificate was not valid. + +### Client Certificate Authentication + +```js +const { readFileSync } = require('fs') +const { join } = require('path') +const { createServer } = require('https') +const { Client } = require('undici') + +const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'server-key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'server-crt.pem'), 'utf8'), + requestCert: true, + rejectUnauthorized: false +} + +const server = createServer(serverOptions, (req, res) => { + // true if client cert is valid + if(req.client.authorized === true) { + console.log('valid') + } else { + console.error(req.client.authorizationError) + } + res.end() +}) + +server.listen(0, function () { + const tls = { + ca: [ + readFileSync(join(__dirname, 'server-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'client-key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'client-crt.pem'), 'utf8'), + rejectUnauthorized: false, + servername: 'agent1' + } + const client = new Client(`https://localhost:${server.address().port}`, { + connect: tls + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.on('data', (buf) => {}) + body.on('end', () => { + client.close() + server.close() + }) + }) +}) +``` diff --git a/docs/best-practices/mocking-request.md b/docs/best-practices/mocking-request.md new file mode 100644 index 0000000..6954392 --- /dev/null +++ b/docs/best-practices/mocking-request.md @@ -0,0 +1,136 @@ +# Mocking Request + +Undici has its own mocking [utility](../api/MockAgent.md). It allow us to intercept undici HTTP requests and return mocked values instead. It can be useful for testing purposes. + +Example: + +```js +// bank.mjs +import { request } from 'undici' + +export async function bankTransfer(recipient, amount) { + const { body } = await request('http://localhost:3000/bank-transfer', + { + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient, + amount + }) + } + ) + return await body.json() +} +``` + +And this is what the test file looks like: + +```js +// index.test.mjs +import { strict as assert } from 'assert' +import { MockAgent, setGlobalDispatcher, } from 'undici' +import { bankTransfer } from './bank.mjs' + +const mockAgent = new MockAgent(); + +setGlobalDispatcher(mockAgent); + +// Provide the base url to the request +const mockPool = mockAgent.get('http://localhost:3000'); + +// intercept the request +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient: '1234567890', + amount: '100' + }) +}).reply(200, { + message: 'transaction processed' +}) + +const success = await bankTransfer('1234567890', '100') + +assert.deepEqual(success, { message: 'transaction processed' }) + +// if you dont want to check whether the body or the headers contain the same value +// just remove it from interceptor +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', +}).reply(400, { + message: 'bank account not found' +}) + +const badRequest = await bankTransfer('1234567890', '100') + +assert.deepEqual(badRequest, { message: 'bank account not found' }) +``` + +Explore other MockAgent functionality [here](../api/MockAgent.md) + +## Debug Mock Value + +When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`: + +```js +const mockAgent = new MockAgent(); + +setGlobalDispatcher(mockAgent); +mockAgent.disableNetConnect() + +// Provide the base url to the request +const mockPool = mockAgent.get('http://localhost:3000'); + +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', +}).reply(200, { + message: 'transaction processed' +}) + +const badRequest = await bankTransfer('1234567890', '100') +// Will throw an error +// MockNotMatchedError: Mock dispatch not matched for path '/bank-transfer': +// subsequent request to origin http://localhost:3000 was not allowed (net.connect disabled) +``` + +## Reply with data based on request + +If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`: + +```js +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient: '1234567890', + amount: '100' + }) +}).reply(200, (opts) => { + // do something with opts + + return { message: 'transaction processed' } +}) +``` + +in this case opts will be + +``` +{ + method: 'POST', + headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' }, + body: '{"recipient":"1234567890","amount":"100"}', + origin: 'http://localhost:3000', + path: '/bank-transfer' +} +``` diff --git a/docs/best-practices/proxy.md b/docs/best-practices/proxy.md new file mode 100644 index 0000000..bf10295 --- /dev/null +++ b/docs/best-practices/proxy.md @@ -0,0 +1,127 @@ +# Connecting through a proxy + +Connecting through a proxy is possible by: + +- Using [AgentProxy](../api/ProxyAgent.md). +- Configuring `Client` or `Pool` constructor. + +The proxy url should be passed to the `Client` or `Pool` constructor, while the upstream server url +should be added to every request call in the `path`. +For instance, if you need to send a request to the `/hello` route of your upstream server, +the `path` should be `path: 'http://upstream.server:port/hello?foo=bar'`. + +If you proxy requires basic authentication, you can send it via the `proxy-authorization` header. + +### Connect without authentication + +```js +import { Client } from 'undici' +import { createServer } from 'http' +import proxy from 'proxy' + +const server = await buildServer() +const proxyServer = await buildProxy() + +const serverUrl = `http://localhost:${server.address().port}` +const proxyUrl = `http://localhost:${proxyServer.address().port}` + +server.on('request', (req, res) => { + console.log(req.url) // '/hello?foo=bar' + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) +}) + +const client = new Client(proxyUrl) + +const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar' +}) + +response.body.setEncoding('utf8') +let data = '' +for await (const chunk of response.body) { + data += chunk +} +console.log(response.statusCode) // 200 +console.log(JSON.parse(data)) // { hello: 'world' } + +server.close() +proxyServer.close() +client.close() + +function buildServer () { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy () { + return new Promise((resolve, reject) => { + const server = proxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} +``` + +### Connect with authentication + +```js +import { Client } from 'undici' +import { createServer } from 'http' +import proxy from 'proxy' + +const server = await buildServer() +const proxyServer = await buildProxy() + +const serverUrl = `http://localhost:${server.address().port}` +const proxyUrl = `http://localhost:${proxyServer.address().port}` + +proxyServer.authenticate = function (req, fn) { + fn(null, req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`) +} + +server.on('request', (req, res) => { + console.log(req.url) // '/hello?foo=bar' + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) +}) + +const client = new Client(proxyUrl) + +const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar', + headers: { + 'proxy-authorization': `Basic ${Buffer.from('user:pass').toString('base64')}` + } +}) + +response.body.setEncoding('utf8') +let data = '' +for await (const chunk of response.body) { + data += chunk +} +console.log(response.statusCode) // 200 +console.log(JSON.parse(data)) // { hello: 'world' } + +server.close() +proxyServer.close() +client.close() + +function buildServer () { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy () { + return new Promise((resolve, reject) => { + const server = proxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} +``` + diff --git a/docs/best-practices/writing-tests.md b/docs/best-practices/writing-tests.md new file mode 100644 index 0000000..57549de --- /dev/null +++ b/docs/best-practices/writing-tests.md @@ -0,0 +1,20 @@ +# Writing tests + +Undici is tuned for a production use case and its default will keep +a socket open for a few seconds after an HTTP request is completed to +remove the overhead of opening up a new socket. These settings that makes +Undici shine in production are not a good fit for using Undici in automated +tests, as it will result in longer execution times. + +The following are good defaults that will keep the socket open for only 10ms: + +```js +import { request, setGlobalDispatcher, Agent } from 'undici' + +const agent = new Agent({ + keepAliveTimeout: 10, // milliseconds + keepAliveMaxTimeout: 10 // milliseconds +}) + +setGlobalDispatcher(agent) +``` diff --git a/docsify/sidebar.md b/docsify/sidebar.md new file mode 100644 index 0000000..b7c7d6a --- /dev/null +++ b/docsify/sidebar.md @@ -0,0 +1,28 @@ + + +* [**Home**](/ "Node.js Undici") +* API + * [Dispatcher](/docs/api/Dispatcher.md "Undici API - Dispatcher") + * [Client](/docs/api/Client.md "Undici API - Client") + * [Pool](/docs/api/Pool.md "Undici API - Pool") + * [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool") + * [Agent](/docs/api/Agent.md "Undici API - Agent") + * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") + * [Connector](/docs/api/Connector.md "Custom connector") + * [Errors](/docs/api/Errors.md "Undici API - Errors") + * [Fetch](/docs/api/Fetch.md "Undici API - Fetch") + * [Cookies](/docs/api/Cookies.md "Undici API - Cookies") + * [MockClient](/docs/api/MockClient.md "Undici API - MockClient") + * [MockPool](/docs/api/MockPool.md "Undici API - MockPool") + * [MockAgent](/docs/api/MockAgent.md "Undici API - MockAgent") + * [MockErrors](/docs/api/MockErrors.md "Undici API - MockErrors") + * [API Lifecycle](/docs/api/api-lifecycle.md "Undici API - Lifecycle") + * [Diagnostics Channel Support](/docs/api/DiagnosticsChannel.md "Diagnostics Channel Support") + * [WebSocket](/docs/api/WebSocket.md "Undici API - WebSocket") + * [MIME Type Parsing](/docs/api/ContentType.md "Undici API - MIME Type Parsing") + * [CacheStorage](/docs/api/CacheStorage.md "Undici API - CacheStorage") +* Best Practices + * [Proxy](/docs/best-practices/proxy.md "Connecting through a proxy") + * [Client Certificate](/docs/best-practices/client-certificate.md "Connect using a client certificate") + * [Writing Tests](/docs/best-practices/writing-tests.md "Using Undici inside tests") + * [Mocking Request](/docs/best-practices/mocking-request.md "Using Undici inside tests") diff --git a/examples/ca-fingerprint/index.js b/examples/ca-fingerprint/index.js new file mode 100644 index 0000000..792c08c --- /dev/null +++ b/examples/ca-fingerprint/index.js @@ -0,0 +1,80 @@ +'use strict' + +const crypto = require('crypto') +const https = require('https') +const { Client, buildConnector } = require('../..') +const pem = require('https-pem') + +const caFingerprint = getFingerprint(pem.cert.toString() + .split('\n') + .slice(1, -1) + .map(line => line.trim()) + .join('') +) + +const server = https.createServer(pem, (req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('hello') +}) + +server.listen(0, function () { + const connector = buildConnector({ rejectUnauthorized: false }) + const client = new Client(`https://localhost:${server.address().port}`, { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) { + socket.destroy() + cb(new Error('Fingerprint does not match or malformed certificate')) + } else { + cb(null, socket) + } + }) + } + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + if (err) throw err + + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + console.log(Buffer.concat(bufs).toString('utf8')) + client.close() + server.close() + }) + }) +}) + +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} + +function getFingerprint (content, inputEncoding = 'base64', outputEncoding = 'hex') { + const shasum = crypto.createHash('sha256') + shasum.update(content, inputEncoding) + const res = shasum.digest(outputEncoding) + return res.toUpperCase().match(/.{1,2}/g).join(':') +} diff --git a/examples/fetch.js b/examples/fetch.js new file mode 100644 index 0000000..7ece2b8 --- /dev/null +++ b/examples/fetch.js @@ -0,0 +1,13 @@ +'use strict' + +const { fetch } = require('../') + +async function main () { + const res = await fetch('http://localhost:3001/') + + const data = await res.text() + console.log('response received', res.status) + console.log('headers', res.headers) + console.log('data', data) +} +main() diff --git a/examples/proxy-agent.js b/examples/proxy-agent.js new file mode 100644 index 0000000..7caf836 --- /dev/null +++ b/examples/proxy-agent.js @@ -0,0 +1,25 @@ +'use strict' + +const { request, setGlobalDispatcher, ProxyAgent } = require('../') + +setGlobalDispatcher(new ProxyAgent('http://localhost:8000/')) + +async function main () { + const { + statusCode, + headers, + trailers, + body + // send the request via the http://localhost:8000/ HTTP proxy + } = await request('http://localhost:3000/undici') + + console.log('response received', statusCode) + console.log('headers', headers) + + for await (const data of body) { + console.log('data', data) + } + + console.log('trailers', trailers) +} +main() diff --git a/examples/proxy/index.js b/examples/proxy/index.js new file mode 100644 index 0000000..5f35049 --- /dev/null +++ b/examples/proxy/index.js @@ -0,0 +1,49 @@ +const { Pool, Client } = require('../../') +const http = require('http') +const proxy = require('./proxy') + +const pool = new Pool('http://localhost:4001', { + connections: 256, + pipelining: 1 +}) + +async function run () { + await Promise.all([ + new Promise(resolve => { + // Proxy + http.createServer((req, res) => { + proxy({ req, res, proxyName: 'example' }, pool).catch(err => { + if (res.headersSent) { + res.destroy(err) + } else { + for (const name of res.getHeaderNames()) { + res.removeHeader(name) + } + res.statusCode = err.statusCode || 500 + res.end() + } + }) + }).listen(4000, resolve) + }), + new Promise(resolve => { + // Upstream + http.createServer((req, res) => { + res.end('hello world') + }).listen(4001, resolve) + }) + ]) + + const client = new Client('http://localhost:4000') + const { body } = await client.request({ + method: 'GET', + path: '/' + }) + + for await (const chunk of body) { + console.log(String(chunk)) + } +} + +run() + +// TODO: Add websocket example. diff --git a/examples/proxy/proxy.js b/examples/proxy/proxy.js new file mode 100644 index 0000000..bb9fcc4 --- /dev/null +++ b/examples/proxy/proxy.js @@ -0,0 +1,256 @@ +const net = require('net') +const { pipeline } = require('stream') +const createError = require('http-errors') + +module.exports = async function proxy (ctx, client) { + const { req, socket, proxyName } = ctx + + const headers = getHeaders({ + headers: req.rawHeaders, + httpVersion: req.httpVersion, + socket: req.socket, + proxyName + }) + + if (socket) { + const handler = new WSHandler(ctx) + client.dispatch({ + method: req.method, + path: req.url, + headers, + upgrade: 'Websocket' + }, handler) + return handler.promise + } else { + const handler = new HTTPHandler(ctx) + client.dispatch({ + method: req.method, + path: req.url, + headers, + body: req + }, handler) + return handler.promise + } +} + +class HTTPHandler { + constructor (ctx) { + const { req, res, proxyName } = ctx + + this.proxyName = proxyName + this.req = req + this.res = res + this.resume = null + this.abort = null + this.promise = new Promise((resolve, reject) => { + this.callback = err => err ? reject(err) : resolve() + }) + } + + onConnect (abort) { + if (this.req.aborted) { + abort() + } else { + this.abort = abort + this.res.on('close', abort) + } + } + + onHeaders (statusCode, headers, resume) { + if (statusCode < 200) { + return + } + + this.resume = resume + this.res.on('drain', resume) + this.res.writeHead(statusCode, getHeaders({ + headers, + proxyName: this.proxyName, + httpVersion: this.httpVersion + })) + } + + onData (chunk) { + return this.res.write(chunk) + } + + onComplete () { + this.res.off('close', this.abort) + this.res.off('drain', this.resume) + + this.res.end() + this.callback() + } + + onError (err) { + this.res.off('close', this.abort) + this.res.off('drain', this.resume) + + this.callback(err) + } +} + +class WSHandler { + constructor (ctx) { + const { req, socket, proxyName, head } = ctx + + setupSocket(socket) + + this.proxyName = proxyName + this.httpVersion = req.httpVersion + this.socket = socket + this.head = head + this.abort = null + this.promise = new Promise((resolve, reject) => { + this.callback = err => err ? reject(err) : resolve() + }) + } + + onConnect (abort) { + if (this.socket.destroyed) { + abort() + } else { + this.abort = abort + this.socket.on('close', abort) + } + } + + onUpgrade (statusCode, headers, socket) { + this.socket.off('close', this.abort) + + // TODO: Check statusCode? + + if (this.head && this.head.length) { + socket.unshift(this.head) + } + + setupSocket(socket) + + headers = getHeaders({ + headers, + proxyName: this.proxyName, + httpVersion: this.httpVersion + }) + + let head = '' + for (let n = 0; n < headers.length; n += 2) { + head += `\r\n${headers[n]}: ${headers[n + 1]}` + } + + this.socket.write(`HTTP/1.1 101 Switching Protocols\r\nconnection: upgrade\r\nupgrade: websocket${head}\r\n\r\n`) + + pipeline(socket, this.socket, socket, this.callback) + } + + onError (err) { + this.socket.off('close', this.abort) + + this.callback(err) + } +} + +// This expression matches hop-by-hop headers. +// These headers are meaningful only for a single transport-level connection, +// and must not be retransmitted by proxies or cached. +const HOP_EXPR = /^(te|host|upgrade|trailers|connection|keep-alive|http2-settings|transfer-encoding|proxy-connection|proxy-authenticate|proxy-authorization)$/i + +// Removes hop-by-hop and pseudo headers. +// Updates via and forwarded headers. +// Only hop-by-hop headers may be set using the Connection general header. +function getHeaders ({ + headers, + proxyName, + httpVersion, + socket +}) { + let via = '' + let forwarded = '' + let host = '' + let authority = '' + let connection = '' + + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n] + const val = headers[n + 1] + + if (!via && key.length === 3 && key.toLowerCase() === 'via') { + via = val + } else if (!host && key.length === 4 && key.toLowerCase() === 'host') { + host = val + } else if (!forwarded && key.length === 9 && key.toLowerCase() === 'forwarded') { + forwarded = val + } else if (!connection && key.length === 10 && key.toLowerCase() === 'connection') { + connection = val + } else if (!authority && key.length === 10 && key === ':authority') { + authority = val + } + } + + let remove + if (connection && !HOP_EXPR.test(connection)) { + remove = connection.split(/,\s*/) + } + + const result = [] + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n] + const val = headers[n + 1] + + if ( + key.charAt(0) !== ':' && + !HOP_EXPR.test(key) && + (!remove || !remove.includes(key)) + ) { + result.push(key, val) + } + } + + if (socket) { + result.push('forwarded', (forwarded ? forwarded + ', ' : '') + [ + `by=${printIp(socket.localAddress, socket.localPort)}`, + `for=${printIp(socket.remoteAddress, socket.remotePort)}`, + `proto=${socket.encrypted ? 'https' : 'http'}`, + `host=${printIp(authority || host || '')}` + ].join(';')) + } else if (forwarded) { + // The forwarded header should not be included in response. + throw new createError.BadGateway() + } + + if (proxyName) { + if (via) { + if (via.split(',').some(name => name.endsWith(proxyName))) { + throw new createError.LoopDetected() + } + via += ', ' + } + via += `${httpVersion} ${proxyName}` + } + + if (via) { + result.push('via', via) + } + + return result +} + +function setupSocket (socket) { + socket.setTimeout(0) + socket.setNoDelay(true) + socket.setKeepAlive(true, 0) +} + +function printIp (address, port) { + const isIPv6 = net.isIPv6(address) + let str = `${address}` + if (isIPv6) { + str = `[${str}]` + } + if (port) { + str = `${str}:${port}` + } + if (isIPv6 || port) { + str = `"${str}"` + } + return str +} diff --git a/examples/request.js b/examples/request.js new file mode 100644 index 0000000..1b03254 --- /dev/null +++ b/examples/request.js @@ -0,0 +1,18 @@ +'use strict' + +const { request } = require('../') + +async function main () { + const { + statusCode, + headers, + body + } = await request('http://localhost:3001/') + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} + +main() diff --git a/index-fetch.js b/index-fetch.js new file mode 100644 index 0000000..ba31a65 --- /dev/null +++ b/index-fetch.js @@ -0,0 +1,15 @@ +'use strict' + +const fetchImpl = require('./lib/fetch').fetch + +module.exports.fetch = function fetch (resource, init = undefined) { + return fetchImpl(resource, init).catch((err) => { + Error.captureStackTrace(err, this) + throw err + }) +} +module.exports.FormData = require('./lib/fetch/formdata').FormData +module.exports.Headers = require('./lib/fetch/headers').Headers +module.exports.Response = require('./lib/fetch/response').Response +module.exports.Request = require('./lib/fetch/request').Request +module.exports.WebSocket = require('./lib/websocket/websocket').WebSocket diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..83a786d --- /dev/null +++ b/index.d.ts @@ -0,0 +1,3 @@ +export * from './types/index' +import Undici from './types/index' +export default Undici diff --git a/index.html b/index.html new file mode 100644 index 0000000..672a592 --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + Node.js Undici + + + + + + + + + +
+ + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..26302cc --- /dev/null +++ b/index.js @@ -0,0 +1,167 @@ +'use strict' + +const Client = require('./lib/client') +const Dispatcher = require('./lib/dispatcher') +const errors = require('./lib/core/errors') +const Pool = require('./lib/pool') +const BalancedPool = require('./lib/balanced-pool') +const Agent = require('./lib/agent') +const util = require('./lib/core/util') +const { InvalidArgumentError } = errors +const api = require('./lib/api') +const buildConnector = require('./lib/core/connect') +const MockClient = require('./lib/mock/mock-client') +const MockAgent = require('./lib/mock/mock-agent') +const MockPool = require('./lib/mock/mock-pool') +const mockErrors = require('./lib/mock/mock-errors') +const ProxyAgent = require('./lib/proxy-agent') +const RetryHandler = require('./lib/handler/RetryHandler') +const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global') +const DecoratorHandler = require('./lib/handler/DecoratorHandler') +const RedirectHandler = require('./lib/handler/RedirectHandler') +const createRedirectInterceptor = require('./lib/interceptor/redirectInterceptor') + +let hasCrypto +try { + require('crypto') + hasCrypto = true +} catch { + hasCrypto = false +} + +Object.assign(Dispatcher.prototype, api) + +module.exports.Dispatcher = Dispatcher +module.exports.Client = Client +module.exports.Pool = Pool +module.exports.BalancedPool = BalancedPool +module.exports.Agent = Agent +module.exports.ProxyAgent = ProxyAgent +module.exports.RetryHandler = RetryHandler + +module.exports.DecoratorHandler = DecoratorHandler +module.exports.RedirectHandler = RedirectHandler +module.exports.createRedirectInterceptor = createRedirectInterceptor + +module.exports.buildConnector = buildConnector +module.exports.errors = errors + +function makeDispatcher (fn) { + return (url, opts, handler) => { + if (typeof opts === 'function') { + handler = opts + opts = null + } + + if (!url || (typeof url !== 'string' && typeof url !== 'object' && !(url instanceof URL))) { + throw new InvalidArgumentError('invalid url') + } + + if (opts != null && typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (opts && opts.path != null) { + if (typeof opts.path !== 'string') { + throw new InvalidArgumentError('invalid opts.path') + } + + let path = opts.path + if (!opts.path.startsWith('/')) { + path = `/${path}` + } + + url = new URL(util.parseOrigin(url).origin + path) + } else { + if (!opts) { + opts = typeof url === 'object' ? url : {} + } + + url = util.parseURL(url) + } + + const { agent, dispatcher = getGlobalDispatcher() } = opts + + if (agent) { + throw new InvalidArgumentError('unsupported opts.agent. Did you mean opts.client?') + } + + return fn.call(dispatcher, { + ...opts, + origin: url.origin, + path: url.search ? `${url.pathname}${url.search}` : url.pathname, + method: opts.method || (opts.body ? 'PUT' : 'GET') + }, handler) + } +} + +module.exports.setGlobalDispatcher = setGlobalDispatcher +module.exports.getGlobalDispatcher = getGlobalDispatcher + +if (util.nodeMajor > 16 || (util.nodeMajor === 16 && util.nodeMinor >= 8)) { + let fetchImpl = null + module.exports.fetch = async function fetch (resource) { + if (!fetchImpl) { + fetchImpl = require('./lib/fetch').fetch + } + + try { + return await fetchImpl(...arguments) + } catch (err) { + if (typeof err === 'object') { + Error.captureStackTrace(err, this) + } + + throw err + } + } + module.exports.Headers = require('./lib/fetch/headers').Headers + module.exports.Response = require('./lib/fetch/response').Response + module.exports.Request = require('./lib/fetch/request').Request + module.exports.FormData = require('./lib/fetch/formdata').FormData + module.exports.File = require('./lib/fetch/file').File + module.exports.FileReader = require('./lib/fileapi/filereader').FileReader + + const { setGlobalOrigin, getGlobalOrigin } = require('./lib/fetch/global') + + module.exports.setGlobalOrigin = setGlobalOrigin + module.exports.getGlobalOrigin = getGlobalOrigin + + const { CacheStorage } = require('./lib/cache/cachestorage') + const { kConstruct } = require('./lib/cache/symbols') + + // Cache & CacheStorage are tightly coupled with fetch. Even if it may run + // in an older version of Node, it doesn't have any use without fetch. + module.exports.caches = new CacheStorage(kConstruct) +} + +if (util.nodeMajor >= 16) { + const { deleteCookie, getCookies, getSetCookies, setCookie } = require('./lib/cookies') + + module.exports.deleteCookie = deleteCookie + module.exports.getCookies = getCookies + module.exports.getSetCookies = getSetCookies + module.exports.setCookie = setCookie + + const { parseMIMEType, serializeAMimeType } = require('./lib/fetch/dataURL') + + module.exports.parseMIMEType = parseMIMEType + module.exports.serializeAMimeType = serializeAMimeType +} + +if (util.nodeMajor >= 18 && hasCrypto) { + const { WebSocket } = require('./lib/websocket/websocket') + + module.exports.WebSocket = WebSocket +} + +module.exports.request = makeDispatcher(api.request) +module.exports.stream = makeDispatcher(api.stream) +module.exports.pipeline = makeDispatcher(api.pipeline) +module.exports.connect = makeDispatcher(api.connect) +module.exports.upgrade = makeDispatcher(api.upgrade) + +module.exports.MockClient = MockClient +module.exports.MockPool = MockPool +module.exports.MockAgent = MockAgent +module.exports.mockErrors = mockErrors diff --git a/lib/agent.js b/lib/agent.js new file mode 100644 index 0000000..0b18f2a --- /dev/null +++ b/lib/agent.js @@ -0,0 +1,148 @@ +'use strict' + +const { InvalidArgumentError } = require('./core/errors') +const { kClients, kRunning, kClose, kDestroy, kDispatch, kInterceptors } = require('./core/symbols') +const DispatcherBase = require('./dispatcher-base') +const Pool = require('./pool') +const Client = require('./client') +const util = require('./core/util') +const createRedirectInterceptor = require('./interceptor/redirectInterceptor') +const { WeakRef, FinalizationRegistry } = require('./compat/dispatcher-weakref')() + +const kOnConnect = Symbol('onConnect') +const kOnDisconnect = Symbol('onDisconnect') +const kOnConnectionError = Symbol('onConnectionError') +const kMaxRedirections = Symbol('maxRedirections') +const kOnDrain = Symbol('onDrain') +const kFactory = Symbol('factory') +const kFinalizer = Symbol('finalizer') +const kOptions = Symbol('options') + +function defaultFactory (origin, opts) { + return opts && opts.connections === 1 + ? new Client(origin, opts) + : new Pool(origin, opts) +} + +class Agent extends DispatcherBase { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { + super() + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + if (connect && typeof connect !== 'function') { + connect = { ...connect } + } + + this[kInterceptors] = options.interceptors && options.interceptors.Agent && Array.isArray(options.interceptors.Agent) + ? options.interceptors.Agent + : [createRedirectInterceptor({ maxRedirections })] + + this[kOptions] = { ...util.deepClone(options), connect } + this[kOptions].interceptors = options.interceptors + ? { ...options.interceptors } + : undefined + this[kMaxRedirections] = maxRedirections + this[kFactory] = factory + this[kClients] = new Map() + this[kFinalizer] = new FinalizationRegistry(/* istanbul ignore next: gc is undeterministic */ key => { + const ref = this[kClients].get(key) + if (ref !== undefined && ref.deref() === undefined) { + this[kClients].delete(key) + } + }) + + const agent = this + + this[kOnDrain] = (origin, targets) => { + agent.emit('drain', origin, [agent, ...targets]) + } + + this[kOnConnect] = (origin, targets) => { + agent.emit('connect', origin, [agent, ...targets]) + } + + this[kOnDisconnect] = (origin, targets, err) => { + agent.emit('disconnect', origin, [agent, ...targets], err) + } + + this[kOnConnectionError] = (origin, targets, err) => { + agent.emit('connectionError', origin, [agent, ...targets], err) + } + } + + get [kRunning] () { + let ret = 0 + for (const ref of this[kClients].values()) { + const client = ref.deref() + /* istanbul ignore next: gc is undeterministic */ + if (client) { + ret += client[kRunning] + } + } + return ret + } + + [kDispatch] (opts, handler) { + let key + if (opts.origin && (typeof opts.origin === 'string' || opts.origin instanceof URL)) { + key = String(opts.origin) + } else { + throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.') + } + + const ref = this[kClients].get(key) + + let dispatcher = ref ? ref.deref() : null + if (!dispatcher) { + dispatcher = this[kFactory](opts.origin, this[kOptions]) + .on('drain', this[kOnDrain]) + .on('connect', this[kOnConnect]) + .on('disconnect', this[kOnDisconnect]) + .on('connectionError', this[kOnConnectionError]) + + this[kClients].set(key, new WeakRef(dispatcher)) + this[kFinalizer].register(dispatcher, key) + } + + return dispatcher.dispatch(opts, handler) + } + + async [kClose] () { + const closePromises = [] + for (const ref of this[kClients].values()) { + const client = ref.deref() + /* istanbul ignore else: gc is undeterministic */ + if (client) { + closePromises.push(client.close()) + } + } + + await Promise.all(closePromises) + } + + async [kDestroy] (err) { + const destroyPromises = [] + for (const ref of this[kClients].values()) { + const client = ref.deref() + /* istanbul ignore else: gc is undeterministic */ + if (client) { + destroyPromises.push(client.destroy(err)) + } + } + + await Promise.all(destroyPromises) + } +} + +module.exports = Agent diff --git a/lib/api/abort-signal.js b/lib/api/abort-signal.js new file mode 100644 index 0000000..2985c1e --- /dev/null +++ b/lib/api/abort-signal.js @@ -0,0 +1,54 @@ +const { addAbortListener } = require('../core/util') +const { RequestAbortedError } = require('../core/errors') + +const kListener = Symbol('kListener') +const kSignal = Symbol('kSignal') + +function abort (self) { + if (self.abort) { + self.abort() + } else { + self.onError(new RequestAbortedError()) + } +} + +function addSignal (self, signal) { + self[kSignal] = null + self[kListener] = null + + if (!signal) { + return + } + + if (signal.aborted) { + abort(self) + return + } + + self[kSignal] = signal + self[kListener] = () => { + abort(self) + } + + addAbortListener(self[kSignal], self[kListener]) +} + +function removeSignal (self) { + if (!self[kSignal]) { + return + } + + if ('removeEventListener' in self[kSignal]) { + self[kSignal].removeEventListener('abort', self[kListener]) + } else { + self[kSignal].removeListener('abort', self[kListener]) + } + + self[kSignal] = null + self[kListener] = null +} + +module.exports = { + addSignal, + removeSignal +} diff --git a/lib/api/api-connect.js b/lib/api/api-connect.js new file mode 100644 index 0000000..fd2b6ad --- /dev/null +++ b/lib/api/api-connect.js @@ -0,0 +1,104 @@ +'use strict' + +const { AsyncResource } = require('async_hooks') +const { InvalidArgumentError, RequestAbortedError, SocketError } = require('../core/errors') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') + +class ConnectHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + const { signal, opaque, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + super('UNDICI_CONNECT') + + this.opaque = opaque || null + this.responseHeaders = responseHeaders || null + this.callback = callback + this.abort = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (!this.callback) { + throw new RequestAbortedError() + } + + this.abort = abort + this.context = context + } + + onHeaders () { + throw new SocketError('bad connect', null) + } + + onUpgrade (statusCode, rawHeaders, socket) { + const { callback, opaque, context } = this + + removeSignal(this) + + this.callback = null + + let headers = rawHeaders + // Indicates is an HTTP2Session + if (headers != null) { + headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + } + + this.runInAsyncScope(callback, null, null, { + statusCode, + headers, + socket, + opaque, + context + }) + } + + onError (err) { + const { callback, opaque } = this + + removeSignal(this) + + if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + } +} + +function connect (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + connect.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const connectHandler = new ConnectHandler(opts, callback) + this.dispatch({ ...opts, method: 'CONNECT' }, connectHandler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts && opts.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = connect diff --git a/lib/api/api-pipeline.js b/lib/api/api-pipeline.js new file mode 100644 index 0000000..af4a180 --- /dev/null +++ b/lib/api/api-pipeline.js @@ -0,0 +1,249 @@ +'use strict' + +const { + Readable, + Duplex, + PassThrough +} = require('stream') +const { + InvalidArgumentError, + InvalidReturnValueError, + RequestAbortedError +} = require('../core/errors') +const util = require('../core/util') +const { AsyncResource } = require('async_hooks') +const { addSignal, removeSignal } = require('./abort-signal') +const assert = require('assert') + +const kResume = Symbol('resume') + +class PipelineRequest extends Readable { + constructor () { + super({ autoDestroy: true }) + + this[kResume] = null + } + + _read () { + const { [kResume]: resume } = this + + if (resume) { + this[kResume] = null + resume() + } + } + + _destroy (err, callback) { + this._read() + + callback(err) + } +} + +class PipelineResponse extends Readable { + constructor (resume) { + super({ autoDestroy: true }) + this[kResume] = resume + } + + _read () { + this[kResume]() + } + + _destroy (err, callback) { + if (!err && !this._readableState.endEmitted) { + err = new RequestAbortedError() + } + + callback(err) + } +} + +class PipelineHandler extends AsyncResource { + constructor (opts, handler) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof handler !== 'function') { + throw new InvalidArgumentError('invalid handler') + } + + const { signal, method, opaque, onInfo, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_PIPELINE') + + this.opaque = opaque || null + this.responseHeaders = responseHeaders || null + this.handler = handler + this.abort = null + this.context = null + this.onInfo = onInfo || null + + this.req = new PipelineRequest().on('error', util.nop) + + this.ret = new Duplex({ + readableObjectMode: opts.objectMode, + autoDestroy: true, + read: () => { + const { body } = this + + if (body && body.resume) { + body.resume() + } + }, + write: (chunk, encoding, callback) => { + const { req } = this + + if (req.push(chunk, encoding) || req._readableState.destroyed) { + callback() + } else { + req[kResume] = callback + } + }, + destroy: (err, callback) => { + const { body, req, res, ret, abort } = this + + if (!err && !ret._readableState.endEmitted) { + err = new RequestAbortedError() + } + + if (abort && err) { + abort() + } + + util.destroy(body, err) + util.destroy(req, err) + util.destroy(res, err) + + removeSignal(this) + + callback(err) + } + }).on('prefinish', () => { + const { req } = this + + // Node < 15 does not call _final in same tick. + req.push(null) + }) + + this.res = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + const { ret, res } = this + + assert(!res, 'pipeline cannot be retried') + + if (ret.destroyed) { + throw new RequestAbortedError() + } + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume) { + const { opaque, handler, context } = this + + if (statusCode < 200) { + if (this.onInfo) { + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + this.onInfo({ statusCode, headers }) + } + return + } + + this.res = new PipelineResponse(resume) + + let body + try { + this.handler = null + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + body = this.runInAsyncScope(handler, null, { + statusCode, + headers, + opaque, + body: this.res, + context + }) + } catch (err) { + this.res.on('error', util.nop) + throw err + } + + if (!body || typeof body.on !== 'function') { + throw new InvalidReturnValueError('expected Readable') + } + + body + .on('data', (chunk) => { + const { ret, body } = this + + if (!ret.push(chunk) && body.pause) { + body.pause() + } + }) + .on('error', (err) => { + const { ret } = this + + util.destroy(ret, err) + }) + .on('end', () => { + const { ret } = this + + ret.push(null) + }) + .on('close', () => { + const { ret } = this + + if (!ret._readableState.ended) { + util.destroy(ret, new RequestAbortedError()) + } + }) + + this.body = body + } + + onData (chunk) { + const { res } = this + return res.push(chunk) + } + + onComplete (trailers) { + const { res } = this + res.push(null) + } + + onError (err) { + const { ret } = this + this.handler = null + util.destroy(ret, err) + } +} + +function pipeline (opts, handler) { + try { + const pipelineHandler = new PipelineHandler(opts, handler) + this.dispatch({ ...opts, body: pipelineHandler.req }, pipelineHandler) + return pipelineHandler.ret + } catch (err) { + return new PassThrough().destroy(err) + } +} + +module.exports = pipeline diff --git a/lib/api/api-request.js b/lib/api/api-request.js new file mode 100644 index 0000000..d4281ce --- /dev/null +++ b/lib/api/api-request.js @@ -0,0 +1,180 @@ +'use strict' + +const Readable = require('./readable') +const { + InvalidArgumentError, + RequestAbortedError +} = require('../core/errors') +const util = require('../core/util') +const { getResolveErrorBodyCallback } = require('./util') +const { AsyncResource } = require('async_hooks') +const { addSignal, removeSignal } = require('./abort-signal') + +class RequestHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + const { signal, method, opaque, body, onInfo, responseHeaders, throwOnError, highWaterMark } = opts + + try { + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (highWaterMark && (typeof highWaterMark !== 'number' || highWaterMark < 0)) { + throw new InvalidArgumentError('invalid highWaterMark') + } + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_REQUEST') + } catch (err) { + if (util.isStream(body)) { + util.destroy(body.on('error', util.nop), err) + } + throw err + } + + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.callback = callback + this.res = null + this.abort = null + this.body = body + this.trailers = {} + this.context = null + this.onInfo = onInfo || null + this.throwOnError = throwOnError + this.highWaterMark = highWaterMark + + if (util.isStream(body)) { + body.on('error', (err) => { + this.onError(err) + }) + } + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (!this.callback) { + throw new RequestAbortedError() + } + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + const { callback, opaque, abort, context, responseHeaders, highWaterMark } = this + + const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + + if (statusCode < 200) { + if (this.onInfo) { + this.onInfo({ statusCode, headers }) + } + return + } + + const parsedHeaders = responseHeaders === 'raw' ? util.parseHeaders(rawHeaders) : headers + const contentType = parsedHeaders['content-type'] + const body = new Readable({ resume, abort, contentType, highWaterMark }) + + this.callback = null + this.res = body + if (callback !== null) { + if (this.throwOnError && statusCode >= 400) { + this.runInAsyncScope(getResolveErrorBodyCallback, null, + { callback, body, contentType, statusCode, statusMessage, headers } + ) + } else { + this.runInAsyncScope(callback, null, null, { + statusCode, + headers, + trailers: this.trailers, + opaque, + body, + context + }) + } + } + } + + onData (chunk) { + const { res } = this + return res.push(chunk) + } + + onComplete (trailers) { + const { res } = this + + removeSignal(this) + + util.parseHeaders(trailers, this.trailers) + + res.push(null) + } + + onError (err) { + const { res, callback, body, opaque } = this + + removeSignal(this) + + if (callback) { + // TODO: Does this need queueMicrotask? + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + + if (res) { + this.res = null + // Ensure all queued handlers are invoked before destroying res. + queueMicrotask(() => { + util.destroy(res, err) + }) + } + + if (body) { + this.body = null + util.destroy(body, err) + } + } +} + +function request (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + request.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + this.dispatch(opts, new RequestHandler(opts, callback)) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts && opts.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = request +module.exports.RequestHandler = RequestHandler diff --git a/lib/api/api-stream.js b/lib/api/api-stream.js new file mode 100644 index 0000000..c571a6f --- /dev/null +++ b/lib/api/api-stream.js @@ -0,0 +1,220 @@ +'use strict' + +const { finished, PassThrough } = require('stream') +const { + InvalidArgumentError, + InvalidReturnValueError, + RequestAbortedError +} = require('../core/errors') +const util = require('../core/util') +const { getResolveErrorBodyCallback } = require('./util') +const { AsyncResource } = require('async_hooks') +const { addSignal, removeSignal } = require('./abort-signal') + +class StreamHandler extends AsyncResource { + constructor (opts, factory, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + const { signal, method, opaque, body, onInfo, responseHeaders, throwOnError } = opts + + try { + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('invalid factory') + } + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_STREAM') + } catch (err) { + if (util.isStream(body)) { + util.destroy(body.on('error', util.nop), err) + } + throw err + } + + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.factory = factory + this.callback = callback + this.res = null + this.abort = null + this.context = null + this.trailers = null + this.body = body + this.onInfo = onInfo || null + this.throwOnError = throwOnError || false + + if (util.isStream(body)) { + body.on('error', (err) => { + this.onError(err) + }) + } + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (!this.callback) { + throw new RequestAbortedError() + } + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + const { factory, opaque, context, callback, responseHeaders } = this + + const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + + if (statusCode < 200) { + if (this.onInfo) { + this.onInfo({ statusCode, headers }) + } + return + } + + this.factory = null + + let res + + if (this.throwOnError && statusCode >= 400) { + const parsedHeaders = responseHeaders === 'raw' ? util.parseHeaders(rawHeaders) : headers + const contentType = parsedHeaders['content-type'] + res = new PassThrough() + + this.callback = null + this.runInAsyncScope(getResolveErrorBodyCallback, null, + { callback, body: res, contentType, statusCode, statusMessage, headers } + ) + } else { + if (factory === null) { + return + } + + res = this.runInAsyncScope(factory, null, { + statusCode, + headers, + opaque, + context + }) + + if ( + !res || + typeof res.write !== 'function' || + typeof res.end !== 'function' || + typeof res.on !== 'function' + ) { + throw new InvalidReturnValueError('expected Writable') + } + + // TODO: Avoid finished. It registers an unnecessary amount of listeners. + finished(res, { readable: false }, (err) => { + const { callback, res, opaque, trailers, abort } = this + + this.res = null + if (err || !res.readable) { + util.destroy(res, err) + } + + this.callback = null + this.runInAsyncScope(callback, null, err || null, { opaque, trailers }) + + if (err) { + abort() + } + }) + } + + res.on('drain', resume) + + this.res = res + + const needDrain = res.writableNeedDrain !== undefined + ? res.writableNeedDrain + : res._writableState && res._writableState.needDrain + + return needDrain !== true + } + + onData (chunk) { + const { res } = this + + return res ? res.write(chunk) : true + } + + onComplete (trailers) { + const { res } = this + + removeSignal(this) + + if (!res) { + return + } + + this.trailers = util.parseHeaders(trailers) + + res.end() + } + + onError (err) { + const { res, callback, opaque, body } = this + + removeSignal(this) + + this.factory = null + + if (res) { + this.res = null + util.destroy(res, err) + } else if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + + if (body) { + this.body = null + util.destroy(body, err) + } + } +} + +function stream (opts, factory, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + stream.call(this, opts, factory, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + this.dispatch(opts, new StreamHandler(opts, factory, callback)) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts && opts.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = stream diff --git a/lib/api/api-upgrade.js b/lib/api/api-upgrade.js new file mode 100644 index 0000000..ef783e8 --- /dev/null +++ b/lib/api/api-upgrade.js @@ -0,0 +1,105 @@ +'use strict' + +const { InvalidArgumentError, RequestAbortedError, SocketError } = require('../core/errors') +const { AsyncResource } = require('async_hooks') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') +const assert = require('assert') + +class UpgradeHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + const { signal, opaque, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + super('UNDICI_UPGRADE') + + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.callback = callback + this.abort = null + this.context = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (!this.callback) { + throw new RequestAbortedError() + } + + this.abort = abort + this.context = null + } + + onHeaders () { + throw new SocketError('bad upgrade', null) + } + + onUpgrade (statusCode, rawHeaders, socket) { + const { callback, opaque, context } = this + + assert.strictEqual(statusCode, 101) + + removeSignal(this) + + this.callback = null + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + this.runInAsyncScope(callback, null, null, { + headers, + socket, + opaque, + context + }) + } + + onError (err) { + const { callback, opaque } = this + + removeSignal(this) + + if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + } +} + +function upgrade (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + upgrade.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const upgradeHandler = new UpgradeHandler(opts, callback) + this.dispatch({ + ...opts, + method: opts.method || 'GET', + upgrade: opts.protocol || 'Websocket' + }, upgradeHandler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts && opts.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = upgrade diff --git a/lib/api/index.js b/lib/api/index.js new file mode 100644 index 0000000..8983a5e --- /dev/null +++ b/lib/api/index.js @@ -0,0 +1,7 @@ +'use strict' + +module.exports.request = require('./api-request') +module.exports.stream = require('./api-stream') +module.exports.pipeline = require('./api-pipeline') +module.exports.upgrade = require('./api-upgrade') +module.exports.connect = require('./api-connect') diff --git a/lib/api/readable.js b/lib/api/readable.js new file mode 100644 index 0000000..5269dfa --- /dev/null +++ b/lib/api/readable.js @@ -0,0 +1,322 @@ +// Ported from https://github.com/nodejs/undici/pull/907 + +'use strict' + +const assert = require('assert') +const { Readable } = require('stream') +const { RequestAbortedError, NotSupportedError, InvalidArgumentError } = require('../core/errors') +const util = require('../core/util') +const { ReadableStreamFrom, toUSVString } = require('../core/util') + +let Blob + +const kConsume = Symbol('kConsume') +const kReading = Symbol('kReading') +const kBody = Symbol('kBody') +const kAbort = Symbol('abort') +const kContentType = Symbol('kContentType') + +const noop = () => {} + +module.exports = class BodyReadable extends Readable { + constructor ({ + resume, + abort, + contentType = '', + highWaterMark = 64 * 1024 // Same as nodejs fs streams. + }) { + super({ + autoDestroy: true, + read: resume, + highWaterMark + }) + + this._readableState.dataEmitted = false + + this[kAbort] = abort + this[kConsume] = null + this[kBody] = null + this[kContentType] = contentType + + // Is stream being consumed through Readable API? + // This is an optimization so that we avoid checking + // for 'data' and 'readable' listeners in the hot path + // inside push(). + this[kReading] = false + } + + destroy (err) { + if (this.destroyed) { + // Node < 16 + return this + } + + if (!err && !this._readableState.endEmitted) { + err = new RequestAbortedError() + } + + if (err) { + this[kAbort]() + } + + return super.destroy(err) + } + + emit (ev, ...args) { + if (ev === 'data') { + // Node < 16.7 + this._readableState.dataEmitted = true + } else if (ev === 'error') { + // Node < 16 + this._readableState.errorEmitted = true + } + return super.emit(ev, ...args) + } + + on (ev, ...args) { + if (ev === 'data' || ev === 'readable') { + this[kReading] = true + } + return super.on(ev, ...args) + } + + addListener (ev, ...args) { + return this.on(ev, ...args) + } + + off (ev, ...args) { + const ret = super.off(ev, ...args) + if (ev === 'data' || ev === 'readable') { + this[kReading] = ( + this.listenerCount('data') > 0 || + this.listenerCount('readable') > 0 + ) + } + return ret + } + + removeListener (ev, ...args) { + return this.off(ev, ...args) + } + + push (chunk) { + if (this[kConsume] && chunk !== null && this.readableLength === 0) { + consumePush(this[kConsume], chunk) + return this[kReading] ? super.push(chunk) : true + } + return super.push(chunk) + } + + // https://fetch.spec.whatwg.org/#dom-body-text + async text () { + return consume(this, 'text') + } + + // https://fetch.spec.whatwg.org/#dom-body-json + async json () { + return consume(this, 'json') + } + + // https://fetch.spec.whatwg.org/#dom-body-blob + async blob () { + return consume(this, 'blob') + } + + // https://fetch.spec.whatwg.org/#dom-body-arraybuffer + async arrayBuffer () { + return consume(this, 'arrayBuffer') + } + + // https://fetch.spec.whatwg.org/#dom-body-formdata + async formData () { + // TODO: Implement. + throw new NotSupportedError() + } + + // https://fetch.spec.whatwg.org/#dom-body-bodyused + get bodyUsed () { + return util.isDisturbed(this) + } + + // https://fetch.spec.whatwg.org/#dom-body-body + get body () { + if (!this[kBody]) { + this[kBody] = ReadableStreamFrom(this) + if (this[kConsume]) { + // TODO: Is this the best way to force a lock? + this[kBody].getReader() // Ensure stream is locked. + assert(this[kBody].locked) + } + } + return this[kBody] + } + + dump (opts) { + let limit = opts && Number.isFinite(opts.limit) ? opts.limit : 262144 + const signal = opts && opts.signal + + if (signal) { + try { + if (typeof signal !== 'object' || !('aborted' in signal)) { + throw new InvalidArgumentError('signal must be an AbortSignal') + } + util.throwIfAborted(signal) + } catch (err) { + return Promise.reject(err) + } + } + + if (this.closed) { + return Promise.resolve(null) + } + + return new Promise((resolve, reject) => { + const signalListenerCleanup = signal + ? util.addAbortListener(signal, () => { + this.destroy() + }) + : noop + + this + .on('close', function () { + signalListenerCleanup() + if (signal && signal.aborted) { + reject(signal.reason || Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } else { + resolve(null) + } + }) + .on('error', noop) + .on('data', function (chunk) { + limit -= chunk.length + if (limit <= 0) { + this.destroy() + } + }) + .resume() + }) + } +} + +// https://streams.spec.whatwg.org/#readablestream-locked +function isLocked (self) { + // Consume is an implicit lock. + return (self[kBody] && self[kBody].locked === true) || self[kConsume] +} + +// https://fetch.spec.whatwg.org/#body-unusable +function isUnusable (self) { + return util.isDisturbed(self) || isLocked(self) +} + +async function consume (stream, type) { + if (isUnusable(stream)) { + throw new TypeError('unusable') + } + + assert(!stream[kConsume]) + + return new Promise((resolve, reject) => { + stream[kConsume] = { + type, + stream, + resolve, + reject, + length: 0, + body: [] + } + + stream + .on('error', function (err) { + consumeFinish(this[kConsume], err) + }) + .on('close', function () { + if (this[kConsume].body !== null) { + consumeFinish(this[kConsume], new RequestAbortedError()) + } + }) + + process.nextTick(consumeStart, stream[kConsume]) + }) +} + +function consumeStart (consume) { + if (consume.body === null) { + return + } + + const { _readableState: state } = consume.stream + + for (const chunk of state.buffer) { + consumePush(consume, chunk) + } + + if (state.endEmitted) { + consumeEnd(this[kConsume]) + } else { + consume.stream.on('end', function () { + consumeEnd(this[kConsume]) + }) + } + + consume.stream.resume() + + while (consume.stream.read() != null) { + // Loop + } +} + +function consumeEnd (consume) { + const { type, body, resolve, stream, length } = consume + + try { + if (type === 'text') { + resolve(toUSVString(Buffer.concat(body))) + } else if (type === 'json') { + resolve(JSON.parse(Buffer.concat(body))) + } else if (type === 'arrayBuffer') { + const dst = new Uint8Array(length) + + let pos = 0 + for (const buf of body) { + dst.set(buf, pos) + pos += buf.byteLength + } + + resolve(dst.buffer) + } else if (type === 'blob') { + if (!Blob) { + Blob = require('buffer').Blob + } + resolve(new Blob(body, { type: stream[kContentType] })) + } + + consumeFinish(consume) + } catch (err) { + stream.destroy(err) + } +} + +function consumePush (consume, chunk) { + consume.length += chunk.length + consume.body.push(chunk) +} + +function consumeFinish (consume, err) { + if (consume.body === null) { + return + } + + if (err) { + consume.reject(err) + } else { + consume.resolve() + } + + consume.type = null + consume.stream = null + consume.resolve = null + consume.reject = null + consume.length = 0 + consume.body = null +} diff --git a/lib/api/util.js b/lib/api/util.js new file mode 100644 index 0000000..bffd702 --- /dev/null +++ b/lib/api/util.js @@ -0,0 +1,46 @@ +const assert = require('assert') +const { + ResponseStatusCodeError +} = require('../core/errors') +const { toUSVString } = require('../core/util') + +async function getResolveErrorBodyCallback ({ callback, body, contentType, statusCode, statusMessage, headers }) { + assert(body) + + let chunks = [] + let limit = 0 + + for await (const chunk of body) { + chunks.push(chunk) + limit += chunk.length + if (limit > 128 * 1024) { + chunks = null + break + } + } + + if (statusCode === 204 || !contentType || !chunks) { + process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers)) + return + } + + try { + if (contentType.startsWith('application/json')) { + const payload = JSON.parse(toUSVString(Buffer.concat(chunks))) + process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers, payload)) + return + } + + if (contentType.startsWith('text/')) { + const payload = toUSVString(Buffer.concat(chunks)) + process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers, payload)) + return + } + } catch (err) { + // Process in a fallback if error + } + + process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers)) +} + +module.exports = { getResolveErrorBodyCallback } diff --git a/lib/balanced-pool.js b/lib/balanced-pool.js new file mode 100644 index 0000000..10bc6a4 --- /dev/null +++ b/lib/balanced-pool.js @@ -0,0 +1,190 @@ +'use strict' + +const { + BalancedPoolMissingUpstreamError, + InvalidArgumentError +} = require('./core/errors') +const { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kRemoveClient, + kGetDispatcher +} = require('./pool-base') +const Pool = require('./pool') +const { kUrl, kInterceptors } = require('./core/symbols') +const { parseOrigin } = require('./core/util') +const kFactory = Symbol('factory') + +const kOptions = Symbol('options') +const kGreatestCommonDivisor = Symbol('kGreatestCommonDivisor') +const kCurrentWeight = Symbol('kCurrentWeight') +const kIndex = Symbol('kIndex') +const kWeight = Symbol('kWeight') +const kMaxWeightPerServer = Symbol('kMaxWeightPerServer') +const kErrorPenalty = Symbol('kErrorPenalty') + +function getGreatestCommonDivisor (a, b) { + if (b === 0) return a + return getGreatestCommonDivisor(b, a % b) +} + +function defaultFactory (origin, opts) { + return new Pool(origin, opts) +} + +class BalancedPool extends PoolBase { + constructor (upstreams = [], { factory = defaultFactory, ...opts } = {}) { + super() + + this[kOptions] = opts + this[kIndex] = -1 + this[kCurrentWeight] = 0 + + this[kMaxWeightPerServer] = this[kOptions].maxWeightPerServer || 100 + this[kErrorPenalty] = this[kOptions].errorPenalty || 15 + + if (!Array.isArray(upstreams)) { + upstreams = [upstreams] + } + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + this[kInterceptors] = opts.interceptors && opts.interceptors.BalancedPool && Array.isArray(opts.interceptors.BalancedPool) + ? opts.interceptors.BalancedPool + : [] + this[kFactory] = factory + + for (const upstream of upstreams) { + this.addUpstream(upstream) + } + this._updateBalancedPoolStats() + } + + addUpstream (upstream) { + const upstreamOrigin = parseOrigin(upstream).origin + + if (this[kClients].find((pool) => ( + pool[kUrl].origin === upstreamOrigin && + pool.closed !== true && + pool.destroyed !== true + ))) { + return this + } + const pool = this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions])) + + this[kAddClient](pool) + pool.on('connect', () => { + pool[kWeight] = Math.min(this[kMaxWeightPerServer], pool[kWeight] + this[kErrorPenalty]) + }) + + pool.on('connectionError', () => { + pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty]) + this._updateBalancedPoolStats() + }) + + pool.on('disconnect', (...args) => { + const err = args[2] + if (err && err.code === 'UND_ERR_SOCKET') { + // decrease the weight of the pool. + pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty]) + this._updateBalancedPoolStats() + } + }) + + for (const client of this[kClients]) { + client[kWeight] = this[kMaxWeightPerServer] + } + + this._updateBalancedPoolStats() + + return this + } + + _updateBalancedPoolStats () { + this[kGreatestCommonDivisor] = this[kClients].map(p => p[kWeight]).reduce(getGreatestCommonDivisor, 0) + } + + removeUpstream (upstream) { + const upstreamOrigin = parseOrigin(upstream).origin + + const pool = this[kClients].find((pool) => ( + pool[kUrl].origin === upstreamOrigin && + pool.closed !== true && + pool.destroyed !== true + )) + + if (pool) { + this[kRemoveClient](pool) + } + + return this + } + + get upstreams () { + return this[kClients] + .filter(dispatcher => dispatcher.closed !== true && dispatcher.destroyed !== true) + .map((p) => p[kUrl].origin) + } + + [kGetDispatcher] () { + // We validate that pools is greater than 0, + // otherwise we would have to wait until an upstream + // is added, which might never happen. + if (this[kClients].length === 0) { + throw new BalancedPoolMissingUpstreamError() + } + + const dispatcher = this[kClients].find(dispatcher => ( + !dispatcher[kNeedDrain] && + dispatcher.closed !== true && + dispatcher.destroyed !== true + )) + + if (!dispatcher) { + return + } + + const allClientsBusy = this[kClients].map(pool => pool[kNeedDrain]).reduce((a, b) => a && b, true) + + if (allClientsBusy) { + return + } + + let counter = 0 + + let maxWeightIndex = this[kClients].findIndex(pool => !pool[kNeedDrain]) + + while (counter++ < this[kClients].length) { + this[kIndex] = (this[kIndex] + 1) % this[kClients].length + const pool = this[kClients][this[kIndex]] + + // find pool index with the largest weight + if (pool[kWeight] > this[kClients][maxWeightIndex][kWeight] && !pool[kNeedDrain]) { + maxWeightIndex = this[kIndex] + } + + // decrease the current weight every `this[kClients].length`. + if (this[kIndex] === 0) { + // Set the current weight to the next lower weight. + this[kCurrentWeight] = this[kCurrentWeight] - this[kGreatestCommonDivisor] + + if (this[kCurrentWeight] <= 0) { + this[kCurrentWeight] = this[kMaxWeightPerServer] + } + } + if (pool[kWeight] >= this[kCurrentWeight] && (!pool[kNeedDrain])) { + return pool + } + } + + this[kCurrentWeight] = this[kClients][maxWeightIndex][kWeight] + this[kIndex] = maxWeightIndex + return this[kClients][maxWeightIndex] + } +} + +module.exports = BalancedPool diff --git a/lib/cache/cache.js b/lib/cache/cache.js new file mode 100644 index 0000000..9b31108 --- /dev/null +++ b/lib/cache/cache.js @@ -0,0 +1,838 @@ +'use strict' + +const { kConstruct } = require('./symbols') +const { urlEquals, fieldValues: getFieldValues } = require('./util') +const { kEnumerableProperty, isDisturbed } = require('../core/util') +const { kHeadersList } = require('../core/symbols') +const { webidl } = require('../fetch/webidl') +const { Response, cloneResponse } = require('../fetch/response') +const { Request } = require('../fetch/request') +const { kState, kHeaders, kGuard, kRealm } = require('../fetch/symbols') +const { fetching } = require('../fetch/index') +const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util') +const assert = require('assert') +const { getGlobalDispatcher } = require('../global') + +/** + * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation + * @typedef {Object} CacheBatchOperation + * @property {'delete' | 'put'} type + * @property {any} request + * @property {any} response + * @property {import('../../types/cache').CacheQueryOptions} options + */ + +/** + * @see https://w3c.github.io/ServiceWorker/#dfn-request-response-list + * @typedef {[any, any][]} requestResponseList + */ + +class Cache { + /** + * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-request-response-list + * @type {requestResponseList} + */ + #relevantRequestResponseList + + constructor () { + if (arguments[0] !== kConstruct) { + webidl.illegalConstructor() + } + + this.#relevantRequestResponseList = arguments[1] + } + + async match (request, options = {}) { + webidl.brandCheck(this, Cache) + webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.match' }) + + request = webidl.converters.RequestInfo(request) + options = webidl.converters.CacheQueryOptions(options) + + const p = await this.matchAll(request, options) + + if (p.length === 0) { + return + } + + return p[0] + } + + async matchAll (request = undefined, options = {}) { + webidl.brandCheck(this, Cache) + + if (request !== undefined) request = webidl.converters.RequestInfo(request) + options = webidl.converters.CacheQueryOptions(options) + + // 1. + let r = null + + // 2. + if (request !== undefined) { + if (request instanceof Request) { + // 2.1.1 + r = request[kState] + + // 2.1.2 + if (r.method !== 'GET' && !options.ignoreMethod) { + return [] + } + } else if (typeof request === 'string') { + // 2.2.1 + r = new Request(request)[kState] + } + } + + // 5. + // 5.1 + const responses = [] + + // 5.2 + if (request === undefined) { + // 5.2.1 + for (const requestResponse of this.#relevantRequestResponseList) { + responses.push(requestResponse[1]) + } + } else { // 5.3 + // 5.3.1 + const requestResponses = this.#queryCache(r, options) + + // 5.3.2 + for (const requestResponse of requestResponses) { + responses.push(requestResponse[1]) + } + } + + // 5.4 + // We don't implement CORs so we don't need to loop over the responses, yay! + + // 5.5.1 + const responseList = [] + + // 5.5.2 + for (const response of responses) { + // 5.5.2.1 + const responseObject = new Response(response.body?.source ?? null) + const body = responseObject[kState].body + responseObject[kState] = response + responseObject[kState].body = body + responseObject[kHeaders][kHeadersList] = response.headersList + responseObject[kHeaders][kGuard] = 'immutable' + + responseList.push(responseObject) + } + + // 6. + return Object.freeze(responseList) + } + + async add (request) { + webidl.brandCheck(this, Cache) + webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.add' }) + + request = webidl.converters.RequestInfo(request) + + // 1. + const requests = [request] + + // 2. + const responseArrayPromise = this.addAll(requests) + + // 3. + return await responseArrayPromise + } + + async addAll (requests) { + webidl.brandCheck(this, Cache) + webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.addAll' }) + + requests = webidl.converters['sequence'](requests) + + // 1. + const responsePromises = [] + + // 2. + const requestList = [] + + // 3. + for (const request of requests) { + if (typeof request === 'string') { + continue + } + + // 3.1 + const r = request[kState] + + // 3.2 + if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') { + throw webidl.errors.exception({ + header: 'Cache.addAll', + message: 'Expected http/s scheme when method is not GET.' + }) + } + } + + // 4. + /** @type {ReturnType[]} */ + const fetchControllers = [] + + // 5. + for (const request of requests) { + // 5.1 + const r = new Request(request)[kState] + + // 5.2 + if (!urlIsHttpHttpsScheme(r.url)) { + throw webidl.errors.exception({ + header: 'Cache.addAll', + message: 'Expected http/s scheme.' + }) + } + + // 5.4 + r.initiator = 'fetch' + r.destination = 'subresource' + + // 5.5 + requestList.push(r) + + // 5.6 + const responsePromise = createDeferredPromise() + + // 5.7 + fetchControllers.push(fetching({ + request: r, + dispatcher: getGlobalDispatcher(), + processResponse (response) { + // 1. + if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) { + responsePromise.reject(webidl.errors.exception({ + header: 'Cache.addAll', + message: 'Received an invalid status code or the request failed.' + })) + } else if (response.headersList.contains('vary')) { // 2. + // 2.1 + const fieldValues = getFieldValues(response.headersList.get('vary')) + + // 2.2 + for (const fieldValue of fieldValues) { + // 2.2.1 + if (fieldValue === '*') { + responsePromise.reject(webidl.errors.exception({ + header: 'Cache.addAll', + message: 'invalid vary field value' + })) + + for (const controller of fetchControllers) { + controller.abort() + } + + return + } + } + } + }, + processResponseEndOfBody (response) { + // 1. + if (response.aborted) { + responsePromise.reject(new DOMException('aborted', 'AbortError')) + return + } + + // 2. + responsePromise.resolve(response) + } + })) + + // 5.8 + responsePromises.push(responsePromise.promise) + } + + // 6. + const p = Promise.all(responsePromises) + + // 7. + const responses = await p + + // 7.1 + const operations = [] + + // 7.2 + let index = 0 + + // 7.3 + for (const response of responses) { + // 7.3.1 + /** @type {CacheBatchOperation} */ + const operation = { + type: 'put', // 7.3.2 + request: requestList[index], // 7.3.3 + response // 7.3.4 + } + + operations.push(operation) // 7.3.5 + + index++ // 7.3.6 + } + + // 7.5 + const cacheJobPromise = createDeferredPromise() + + // 7.6.1 + let errorData = null + + // 7.6.2 + try { + this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + // 7.6.3 + queueMicrotask(() => { + // 7.6.3.1 + if (errorData === null) { + cacheJobPromise.resolve(undefined) + } else { + // 7.6.3.2 + cacheJobPromise.reject(errorData) + } + }) + + // 7.7 + return cacheJobPromise.promise + } + + async put (request, response) { + webidl.brandCheck(this, Cache) + webidl.argumentLengthCheck(arguments, 2, { header: 'Cache.put' }) + + request = webidl.converters.RequestInfo(request) + response = webidl.converters.Response(response) + + // 1. + let innerRequest = null + + // 2. + if (request instanceof Request) { + innerRequest = request[kState] + } else { // 3. + innerRequest = new Request(request)[kState] + } + + // 4. + if (!urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET') { + throw webidl.errors.exception({ + header: 'Cache.put', + message: 'Expected an http/s scheme when method is not GET' + }) + } + + // 5. + const innerResponse = response[kState] + + // 6. + if (innerResponse.status === 206) { + throw webidl.errors.exception({ + header: 'Cache.put', + message: 'Got 206 status' + }) + } + + // 7. + if (innerResponse.headersList.contains('vary')) { + // 7.1. + const fieldValues = getFieldValues(innerResponse.headersList.get('vary')) + + // 7.2. + for (const fieldValue of fieldValues) { + // 7.2.1 + if (fieldValue === '*') { + throw webidl.errors.exception({ + header: 'Cache.put', + message: 'Got * vary field value' + }) + } + } + } + + // 8. + if (innerResponse.body && (isDisturbed(innerResponse.body.stream) || innerResponse.body.stream.locked)) { + throw webidl.errors.exception({ + header: 'Cache.put', + message: 'Response body is locked or disturbed' + }) + } + + // 9. + const clonedResponse = cloneResponse(innerResponse) + + // 10. + const bodyReadPromise = createDeferredPromise() + + // 11. + if (innerResponse.body != null) { + // 11.1 + const stream = innerResponse.body.stream + + // 11.2 + const reader = stream.getReader() + + // 11.3 + readAllBytes(reader).then(bodyReadPromise.resolve, bodyReadPromise.reject) + } else { + bodyReadPromise.resolve(undefined) + } + + // 12. + /** @type {CacheBatchOperation[]} */ + const operations = [] + + // 13. + /** @type {CacheBatchOperation} */ + const operation = { + type: 'put', // 14. + request: innerRequest, // 15. + response: clonedResponse // 16. + } + + // 17. + operations.push(operation) + + // 19. + const bytes = await bodyReadPromise.promise + + if (clonedResponse.body != null) { + clonedResponse.body.source = bytes + } + + // 19.1 + const cacheJobPromise = createDeferredPromise() + + // 19.2.1 + let errorData = null + + // 19.2.2 + try { + this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + // 19.2.3 + queueMicrotask(() => { + // 19.2.3.1 + if (errorData === null) { + cacheJobPromise.resolve() + } else { // 19.2.3.2 + cacheJobPromise.reject(errorData) + } + }) + + return cacheJobPromise.promise + } + + async delete (request, options = {}) { + webidl.brandCheck(this, Cache) + webidl.argumentLengthCheck(arguments, 1, { header: 'Cache.delete' }) + + request = webidl.converters.RequestInfo(request) + options = webidl.converters.CacheQueryOptions(options) + + /** + * @type {Request} + */ + let r = null + + if (request instanceof Request) { + r = request[kState] + + if (r.method !== 'GET' && !options.ignoreMethod) { + return false + } + } else { + assert(typeof request === 'string') + + r = new Request(request)[kState] + } + + /** @type {CacheBatchOperation[]} */ + const operations = [] + + /** @type {CacheBatchOperation} */ + const operation = { + type: 'delete', + request: r, + options + } + + operations.push(operation) + + const cacheJobPromise = createDeferredPromise() + + let errorData = null + let requestResponses + + try { + requestResponses = this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + queueMicrotask(() => { + if (errorData === null) { + cacheJobPromise.resolve(!!requestResponses?.length) + } else { + cacheJobPromise.reject(errorData) + } + }) + + return cacheJobPromise.promise + } + + /** + * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys + * @param {any} request + * @param {import('../../types/cache').CacheQueryOptions} options + * @returns {readonly Request[]} + */ + async keys (request = undefined, options = {}) { + webidl.brandCheck(this, Cache) + + if (request !== undefined) request = webidl.converters.RequestInfo(request) + options = webidl.converters.CacheQueryOptions(options) + + // 1. + let r = null + + // 2. + if (request !== undefined) { + // 2.1 + if (request instanceof Request) { + // 2.1.1 + r = request[kState] + + // 2.1.2 + if (r.method !== 'GET' && !options.ignoreMethod) { + return [] + } + } else if (typeof request === 'string') { // 2.2 + r = new Request(request)[kState] + } + } + + // 4. + const promise = createDeferredPromise() + + // 5. + // 5.1 + const requests = [] + + // 5.2 + if (request === undefined) { + // 5.2.1 + for (const requestResponse of this.#relevantRequestResponseList) { + // 5.2.1.1 + requests.push(requestResponse[0]) + } + } else { // 5.3 + // 5.3.1 + const requestResponses = this.#queryCache(r, options) + + // 5.3.2 + for (const requestResponse of requestResponses) { + // 5.3.2.1 + requests.push(requestResponse[0]) + } + } + + // 5.4 + queueMicrotask(() => { + // 5.4.1 + const requestList = [] + + // 5.4.2 + for (const request of requests) { + const requestObject = new Request('https://a') + requestObject[kState] = request + requestObject[kHeaders][kHeadersList] = request.headersList + requestObject[kHeaders][kGuard] = 'immutable' + requestObject[kRealm] = request.client + + // 5.4.2.1 + requestList.push(requestObject) + } + + // 5.4.3 + promise.resolve(Object.freeze(requestList)) + }) + + return promise.promise + } + + /** + * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm + * @param {CacheBatchOperation[]} operations + * @returns {requestResponseList} + */ + #batchCacheOperations (operations) { + // 1. + const cache = this.#relevantRequestResponseList + + // 2. + const backupCache = [...cache] + + // 3. + const addedItems = [] + + // 4.1 + const resultList = [] + + try { + // 4.2 + for (const operation of operations) { + // 4.2.1 + if (operation.type !== 'delete' && operation.type !== 'put') { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'operation type does not match "delete" or "put"' + }) + } + + // 4.2.2 + if (operation.type === 'delete' && operation.response != null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'delete operation should not have an associated response' + }) + } + + // 4.2.3 + if (this.#queryCache(operation.request, operation.options, addedItems).length) { + throw new DOMException('???', 'InvalidStateError') + } + + // 4.2.4 + let requestResponses + + // 4.2.5 + if (operation.type === 'delete') { + // 4.2.5.1 + requestResponses = this.#queryCache(operation.request, operation.options) + + // TODO: the spec is wrong, this is needed to pass WPTs + if (requestResponses.length === 0) { + return [] + } + + // 4.2.5.2 + for (const requestResponse of requestResponses) { + const idx = cache.indexOf(requestResponse) + assert(idx !== -1) + + // 4.2.5.2.1 + cache.splice(idx, 1) + } + } else if (operation.type === 'put') { // 4.2.6 + // 4.2.6.1 + if (operation.response == null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'put operation should have an associated response' + }) + } + + // 4.2.6.2 + const r = operation.request + + // 4.2.6.3 + if (!urlIsHttpHttpsScheme(r.url)) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'expected http or https scheme' + }) + } + + // 4.2.6.4 + if (r.method !== 'GET') { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'not get method' + }) + } + + // 4.2.6.5 + if (operation.options != null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'options must not be defined' + }) + } + + // 4.2.6.6 + requestResponses = this.#queryCache(operation.request) + + // 4.2.6.7 + for (const requestResponse of requestResponses) { + const idx = cache.indexOf(requestResponse) + assert(idx !== -1) + + // 4.2.6.7.1 + cache.splice(idx, 1) + } + + // 4.2.6.8 + cache.push([operation.request, operation.response]) + + // 4.2.6.10 + addedItems.push([operation.request, operation.response]) + } + + // 4.2.7 + resultList.push([operation.request, operation.response]) + } + + // 4.3 + return resultList + } catch (e) { // 5. + // 5.1 + this.#relevantRequestResponseList.length = 0 + + // 5.2 + this.#relevantRequestResponseList = backupCache + + // 5.3 + throw e + } + } + + /** + * @see https://w3c.github.io/ServiceWorker/#query-cache + * @param {any} requestQuery + * @param {import('../../types/cache').CacheQueryOptions} options + * @param {requestResponseList} targetStorage + * @returns {requestResponseList} + */ + #queryCache (requestQuery, options, targetStorage) { + /** @type {requestResponseList} */ + const resultList = [] + + const storage = targetStorage ?? this.#relevantRequestResponseList + + for (const requestResponse of storage) { + const [cachedRequest, cachedResponse] = requestResponse + if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) { + resultList.push(requestResponse) + } + } + + return resultList + } + + /** + * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm + * @param {any} requestQuery + * @param {any} request + * @param {any | null} response + * @param {import('../../types/cache').CacheQueryOptions | undefined} options + * @returns {boolean} + */ + #requestMatchesCachedItem (requestQuery, request, response = null, options) { + // if (options?.ignoreMethod === false && request.method === 'GET') { + // return false + // } + + const queryURL = new URL(requestQuery.url) + + const cachedURL = new URL(request.url) + + if (options?.ignoreSearch) { + cachedURL.search = '' + + queryURL.search = '' + } + + if (!urlEquals(queryURL, cachedURL, true)) { + return false + } + + if ( + response == null || + options?.ignoreVary || + !response.headersList.contains('vary') + ) { + return true + } + + const fieldValues = getFieldValues(response.headersList.get('vary')) + + for (const fieldValue of fieldValues) { + if (fieldValue === '*') { + return false + } + + const requestValue = request.headersList.get(fieldValue) + const queryValue = requestQuery.headersList.get(fieldValue) + + // If one has the header and the other doesn't, or one has + // a different value than the other, return false + if (requestValue !== queryValue) { + return false + } + } + + return true + } +} + +Object.defineProperties(Cache.prototype, { + [Symbol.toStringTag]: { + value: 'Cache', + configurable: true + }, + match: kEnumerableProperty, + matchAll: kEnumerableProperty, + add: kEnumerableProperty, + addAll: kEnumerableProperty, + put: kEnumerableProperty, + delete: kEnumerableProperty, + keys: kEnumerableProperty +}) + +const cacheQueryOptionConverters = [ + { + key: 'ignoreSearch', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'ignoreMethod', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'ignoreVary', + converter: webidl.converters.boolean, + defaultValue: false + } +] + +webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters) + +webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([ + ...cacheQueryOptionConverters, + { + key: 'cacheName', + converter: webidl.converters.DOMString + } +]) + +webidl.converters.Response = webidl.interfaceConverter(Response) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.RequestInfo +) + +module.exports = { + Cache +} diff --git a/lib/cache/cachestorage.js b/lib/cache/cachestorage.js new file mode 100644 index 0000000..7e7f0cf --- /dev/null +++ b/lib/cache/cachestorage.js @@ -0,0 +1,144 @@ +'use strict' + +const { kConstruct } = require('./symbols') +const { Cache } = require('./cache') +const { webidl } = require('../fetch/webidl') +const { kEnumerableProperty } = require('../core/util') + +class CacheStorage { + /** + * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-name-to-cache-map + * @type {Map} + */ + async has (cacheName) { + webidl.brandCheck(this, CacheStorage) + webidl.argumentLengthCheck(arguments, 1, { header: 'CacheStorage.has' }) + + cacheName = webidl.converters.DOMString(cacheName) + + // 2.1.1 + // 2.2 + return this.#caches.has(cacheName) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#dom-cachestorage-open + * @param {string} cacheName + * @returns {Promise} + */ + async open (cacheName) { + webidl.brandCheck(this, CacheStorage) + webidl.argumentLengthCheck(arguments, 1, { header: 'CacheStorage.open' }) + + cacheName = webidl.converters.DOMString(cacheName) + + // 2.1 + if (this.#caches.has(cacheName)) { + // await caches.open('v1') !== await caches.open('v1') + + // 2.1.1 + const cache = this.#caches.get(cacheName) + + // 2.1.1.1 + return new Cache(kConstruct, cache) + } + + // 2.2 + const cache = [] + + // 2.3 + this.#caches.set(cacheName, cache) + + // 2.4 + return new Cache(kConstruct, cache) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#cache-storage-delete + * @param {string} cacheName + * @returns {Promise} + */ + async delete (cacheName) { + webidl.brandCheck(this, CacheStorage) + webidl.argumentLengthCheck(arguments, 1, { header: 'CacheStorage.delete' }) + + cacheName = webidl.converters.DOMString(cacheName) + + return this.#caches.delete(cacheName) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#cache-storage-keys + * @returns {string[]} + */ + async keys () { + webidl.brandCheck(this, CacheStorage) + + // 2.1 + const keys = this.#caches.keys() + + // 2.2 + return [...keys] + } +} + +Object.defineProperties(CacheStorage.prototype, { + [Symbol.toStringTag]: { + value: 'CacheStorage', + configurable: true + }, + match: kEnumerableProperty, + has: kEnumerableProperty, + open: kEnumerableProperty, + delete: kEnumerableProperty, + keys: kEnumerableProperty +}) + +module.exports = { + CacheStorage +} diff --git a/lib/cache/symbols.js b/lib/cache/symbols.js new file mode 100644 index 0000000..40448d6 --- /dev/null +++ b/lib/cache/symbols.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = { + kConstruct: require('../core/symbols').kConstruct +} diff --git a/lib/cache/util.js b/lib/cache/util.js new file mode 100644 index 0000000..44d52b7 --- /dev/null +++ b/lib/cache/util.js @@ -0,0 +1,49 @@ +'use strict' + +const assert = require('assert') +const { URLSerializer } = require('../fetch/dataURL') +const { isValidHeaderName } = require('../fetch/util') + +/** + * @see https://url.spec.whatwg.org/#concept-url-equals + * @param {URL} A + * @param {URL} B + * @param {boolean | undefined} excludeFragment + * @returns {boolean} + */ +function urlEquals (A, B, excludeFragment = false) { + const serializedA = URLSerializer(A, excludeFragment) + + const serializedB = URLSerializer(B, excludeFragment) + + return serializedA === serializedB +} + +/** + * @see https://github.com/chromium/chromium/blob/694d20d134cb553d8d89e5500b9148012b1ba299/content/browser/cache_storage/cache_storage_cache.cc#L260-L262 + * @param {string} header + */ +function fieldValues (header) { + assert(header !== null) + + const values = [] + + for (let value of header.split(',')) { + value = value.trim() + + if (!value.length) { + continue + } else if (!isValidHeaderName(value)) { + continue + } + + values.push(value) + } + + return values +} + +module.exports = { + urlEquals, + fieldValues +} diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..22cb390 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,2283 @@ +// @ts-check + +'use strict' + +/* global WebAssembly */ + +const assert = require('assert') +const net = require('net') +const http = require('http') +const { pipeline } = require('stream') +const util = require('./core/util') +const timers = require('./timers') +const Request = require('./core/request') +const DispatcherBase = require('./dispatcher-base') +const { + RequestContentLengthMismatchError, + ResponseContentLengthMismatchError, + InvalidArgumentError, + RequestAbortedError, + HeadersTimeoutError, + HeadersOverflowError, + SocketError, + InformationalError, + BodyTimeoutError, + HTTPParserError, + ResponseExceededMaxSizeError, + ClientDestroyedError +} = require('./core/errors') +const buildConnector = require('./core/connect') +const { + kUrl, + kReset, + kServerName, + kClient, + kBusy, + kParser, + kConnect, + kBlocking, + kResuming, + kRunning, + kPending, + kSize, + kWriting, + kQueue, + kConnected, + kConnecting, + kNeedDrain, + kNoRef, + kKeepAliveDefaultTimeout, + kHostHeader, + kPendingIdx, + kRunningIdx, + kError, + kPipelining, + kSocket, + kKeepAliveTimeoutValue, + kMaxHeadersSize, + kKeepAliveMaxTimeout, + kKeepAliveTimeoutThreshold, + kHeadersTimeout, + kBodyTimeout, + kStrictContentLength, + kConnector, + kMaxRedirections, + kMaxRequests, + kCounter, + kClose, + kDestroy, + kDispatch, + kInterceptors, + kLocalAddress, + kMaxResponseSize, + kHTTPConnVersion, + // HTTP2 + kHost, + kHTTP2Session, + kHTTP2SessionState, + kHTTP2BuildRequest, + kHTTP2CopyHeaders, + kHTTP1BuildRequest +} = require('./core/symbols') + +/** @type {import('http2')} */ +let http2 +try { + http2 = require('http2') +} catch { + // @ts-ignore + http2 = { constants: {} } +} + +const { + constants: { + HTTP2_HEADER_AUTHORITY, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_HEADER_SCHEME, + HTTP2_HEADER_CONTENT_LENGTH, + HTTP2_HEADER_EXPECT, + HTTP2_HEADER_STATUS + } +} = http2 + +// Experimental +let h2ExperimentalWarned = false + +const FastBuffer = Buffer[Symbol.species] + +const kClosedResolve = Symbol('kClosedResolve') + +const channels = {} + +try { + const diagnosticsChannel = require('diagnostics_channel') + channels.sendHeaders = diagnosticsChannel.channel('undici:client:sendHeaders') + channels.beforeConnect = diagnosticsChannel.channel('undici:client:beforeConnect') + channels.connectError = diagnosticsChannel.channel('undici:client:connectError') + channels.connected = diagnosticsChannel.channel('undici:client:connected') +} catch { + channels.sendHeaders = { hasSubscribers: false } + channels.beforeConnect = { hasSubscribers: false } + channels.connectError = { hasSubscribers: false } + channels.connected = { hasSubscribers: false } +} + +/** + * @type {import('../types/client').default} + */ +class Client extends DispatcherBase { + /** + * + * @param {string|URL} url + * @param {import('../types/client').Client.Options} options + */ + constructor (url, { + interceptors, + maxHeaderSize, + headersTimeout, + socketTimeout, + requestTimeout, + connectTimeout, + bodyTimeout, + idleTimeout, + keepAlive, + keepAliveTimeout, + maxKeepAliveTimeout, + keepAliveMaxTimeout, + keepAliveTimeoutThreshold, + socketPath, + pipelining, + tls, + strictContentLength, + maxCachedSessions, + maxRedirections, + connect, + maxRequestsPerClient, + localAddress, + maxResponseSize, + autoSelectFamily, + autoSelectFamilyAttemptTimeout, + // h2 + allowH2, + maxConcurrentStreams + } = {}) { + super() + + if (keepAlive !== undefined) { + throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') + } + + if (socketTimeout !== undefined) { + throw new InvalidArgumentError('unsupported socketTimeout, use headersTimeout & bodyTimeout instead') + } + + if (requestTimeout !== undefined) { + throw new InvalidArgumentError('unsupported requestTimeout, use headersTimeout & bodyTimeout instead') + } + + if (idleTimeout !== undefined) { + throw new InvalidArgumentError('unsupported idleTimeout, use keepAliveTimeout instead') + } + + if (maxKeepAliveTimeout !== undefined) { + throw new InvalidArgumentError('unsupported maxKeepAliveTimeout, use keepAliveMaxTimeout instead') + } + + if (maxHeaderSize != null && !Number.isFinite(maxHeaderSize)) { + throw new InvalidArgumentError('invalid maxHeaderSize') + } + + if (socketPath != null && typeof socketPath !== 'string') { + throw new InvalidArgumentError('invalid socketPath') + } + + if (connectTimeout != null && (!Number.isFinite(connectTimeout) || connectTimeout < 0)) { + throw new InvalidArgumentError('invalid connectTimeout') + } + + if (keepAliveTimeout != null && (!Number.isFinite(keepAliveTimeout) || keepAliveTimeout <= 0)) { + throw new InvalidArgumentError('invalid keepAliveTimeout') + } + + if (keepAliveMaxTimeout != null && (!Number.isFinite(keepAliveMaxTimeout) || keepAliveMaxTimeout <= 0)) { + throw new InvalidArgumentError('invalid keepAliveMaxTimeout') + } + + if (keepAliveTimeoutThreshold != null && !Number.isFinite(keepAliveTimeoutThreshold)) { + throw new InvalidArgumentError('invalid keepAliveTimeoutThreshold') + } + + if (headersTimeout != null && (!Number.isInteger(headersTimeout) || headersTimeout < 0)) { + throw new InvalidArgumentError('headersTimeout must be a positive integer or zero') + } + + if (bodyTimeout != null && (!Number.isInteger(bodyTimeout) || bodyTimeout < 0)) { + throw new InvalidArgumentError('bodyTimeout must be a positive integer or zero') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + if (maxRequestsPerClient != null && (!Number.isInteger(maxRequestsPerClient) || maxRequestsPerClient < 0)) { + throw new InvalidArgumentError('maxRequestsPerClient must be a positive number') + } + + if (localAddress != null && (typeof localAddress !== 'string' || net.isIP(localAddress) === 0)) { + throw new InvalidArgumentError('localAddress must be valid string IP address') + } + + if (maxResponseSize != null && (!Number.isInteger(maxResponseSize) || maxResponseSize < -1)) { + throw new InvalidArgumentError('maxResponseSize must be a positive number') + } + + if ( + autoSelectFamilyAttemptTimeout != null && + (!Number.isInteger(autoSelectFamilyAttemptTimeout) || autoSelectFamilyAttemptTimeout < -1) + ) { + throw new InvalidArgumentError('autoSelectFamilyAttemptTimeout must be a positive number') + } + + // h2 + if (allowH2 != null && typeof allowH2 !== 'boolean') { + throw new InvalidArgumentError('allowH2 must be a valid boolean value') + } + + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('maxConcurrentStreams must be a possitive integer, greater than 0') + } + + if (typeof connect !== 'function') { + connect = buildConnector({ + ...tls, + maxCachedSessions, + allowH2, + socketPath, + timeout: connectTimeout, + ...(util.nodeHasAutoSelectFamily && autoSelectFamily ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), + ...connect + }) + } + + this[kInterceptors] = interceptors && interceptors.Client && Array.isArray(interceptors.Client) + ? interceptors.Client + : [createRedirectInterceptor({ maxRedirections })] + this[kUrl] = util.parseOrigin(url) + this[kConnector] = connect + this[kSocket] = null + this[kPipelining] = pipelining != null ? pipelining : 1 + this[kMaxHeadersSize] = maxHeaderSize || http.maxHeaderSize + this[kKeepAliveDefaultTimeout] = keepAliveTimeout == null ? 4e3 : keepAliveTimeout + this[kKeepAliveMaxTimeout] = keepAliveMaxTimeout == null ? 600e3 : keepAliveMaxTimeout + this[kKeepAliveTimeoutThreshold] = keepAliveTimeoutThreshold == null ? 1e3 : keepAliveTimeoutThreshold + this[kKeepAliveTimeoutValue] = this[kKeepAliveDefaultTimeout] + this[kServerName] = null + this[kLocalAddress] = localAddress != null ? localAddress : null + this[kResuming] = 0 // 0, idle, 1, scheduled, 2 resuming + this[kNeedDrain] = 0 // 0, idle, 1, scheduled, 2 resuming + this[kHostHeader] = `host: ${this[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}\r\n` + this[kBodyTimeout] = bodyTimeout != null ? bodyTimeout : 300e3 + this[kHeadersTimeout] = headersTimeout != null ? headersTimeout : 300e3 + this[kStrictContentLength] = strictContentLength == null ? true : strictContentLength + this[kMaxRedirections] = maxRedirections + this[kMaxRequests] = maxRequestsPerClient + this[kClosedResolve] = null + this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1 + this[kHTTPConnVersion] = 'h1' + + // HTTP/2 + this[kHTTP2Session] = null + this[kHTTP2SessionState] = !allowH2 + ? null + : { + // streams: null, // Fixed queue of streams - For future support of `push` + openStreams: 0, // Keep track of them to decide wether or not unref the session + maxConcurrentStreams: maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server + } + this[kHost] = `${this[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}` + + // kQueue is built up of 3 sections separated by + // the kRunningIdx and kPendingIdx indices. + // | complete | running | pending | + // ^ kRunningIdx ^ kPendingIdx ^ kQueue.length + // kRunningIdx points to the first running element. + // kPendingIdx points to the first pending element. + // This implements a fast queue with an amortized + // time of O(1). + + this[kQueue] = [] + this[kRunningIdx] = 0 + this[kPendingIdx] = 0 + } + + get pipelining () { + return this[kPipelining] + } + + set pipelining (value) { + this[kPipelining] = value + resume(this, true) + } + + get [kPending] () { + return this[kQueue].length - this[kPendingIdx] + } + + get [kRunning] () { + return this[kPendingIdx] - this[kRunningIdx] + } + + get [kSize] () { + return this[kQueue].length - this[kRunningIdx] + } + + get [kConnected] () { + return !!this[kSocket] && !this[kConnecting] && !this[kSocket].destroyed + } + + get [kBusy] () { + const socket = this[kSocket] + return ( + (socket && (socket[kReset] || socket[kWriting] || socket[kBlocking])) || + (this[kSize] >= (this[kPipelining] || 1)) || + this[kPending] > 0 + ) + } + + /* istanbul ignore: only used for test */ + [kConnect] (cb) { + connect(this) + this.once('connect', cb) + } + + [kDispatch] (opts, handler) { + const origin = opts.origin || this[kUrl].origin + + const request = this[kHTTPConnVersion] === 'h2' + ? Request[kHTTP2BuildRequest](origin, opts, handler) + : Request[kHTTP1BuildRequest](origin, opts, handler) + + this[kQueue].push(request) + if (this[kResuming]) { + // Do nothing. + } else if (util.bodyLength(request.body) == null && util.isIterable(request.body)) { + // Wait a tick in case stream/iterator is ended in the same tick. + this[kResuming] = 1 + process.nextTick(resume, this) + } else { + resume(this, true) + } + + if (this[kResuming] && this[kNeedDrain] !== 2 && this[kBusy]) { + this[kNeedDrain] = 2 + } + + return this[kNeedDrain] < 2 + } + + async [kClose] () { + // TODO: for H2 we need to gracefully flush the remaining enqueued + // request and close each stream. + return new Promise((resolve) => { + if (!this[kSize]) { + resolve(null) + } else { + this[kClosedResolve] = resolve + } + }) + } + + async [kDestroy] (err) { + return new Promise((resolve) => { + const requests = this[kQueue].splice(this[kPendingIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + errorRequest(this, request, err) + } + + const callback = () => { + if (this[kClosedResolve]) { + // TODO (fix): Should we error here with ClientDestroyedError? + this[kClosedResolve]() + this[kClosedResolve] = null + } + resolve() + } + + if (this[kHTTP2Session] != null) { + util.destroy(this[kHTTP2Session], err) + this[kHTTP2Session] = null + this[kHTTP2SessionState] = null + } + + if (!this[kSocket]) { + queueMicrotask(callback) + } else { + util.destroy(this[kSocket].on('close', callback), err) + } + + resume(this) + }) + } +} + +function onHttp2SessionError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + + this[kSocket][kError] = err + + onError(this[kClient], err) +} + +function onHttp2FrameError (type, code, id) { + const err = new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`) + + if (id === 0) { + this[kSocket][kError] = err + onError(this[kClient], err) + } +} + +function onHttp2SessionEnd () { + util.destroy(this, new SocketError('other side closed')) + util.destroy(this[kSocket], new SocketError('other side closed')) +} + +function onHTTP2GoAway (code) { + const client = this[kClient] + const err = new InformationalError(`HTTP/2: "GOAWAY" frame received with code ${code}`) + client[kSocket] = null + client[kHTTP2Session] = null + + if (client.destroyed) { + assert(this[kPending] === 0) + + // Fail entire queue. + const requests = client[kQueue].splice(client[kRunningIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + errorRequest(this, request, err) + } + } else if (client[kRunning] > 0) { + // Fail head of pipeline. + const request = client[kQueue][client[kRunningIdx]] + client[kQueue][client[kRunningIdx]++] = null + + errorRequest(client, request, err) + } + + client[kPendingIdx] = client[kRunningIdx] + + assert(client[kRunning] === 0) + + client.emit('disconnect', + client[kUrl], + [client], + err + ) + + resume(client) +} + +const constants = require('./llhttp/constants') +const createRedirectInterceptor = require('./interceptor/redirectInterceptor') +const EMPTY_BUF = Buffer.alloc(0) + +async function lazyllhttp () { + const llhttpWasmData = process.env.JEST_WORKER_ID ? require('./llhttp/llhttp-wasm.js') : undefined + + let mod + try { + mod = await WebAssembly.compile(Buffer.from(require('./llhttp/llhttp_simd-wasm.js'), 'base64')) + } catch (e) { + /* istanbul ignore next */ + + // We could check if the error was caused by the simd option not + // being enabled, but the occurring of this other error + // * https://github.com/emscripten-core/emscripten/issues/11495 + // got me to remove that check to avoid breaking Node 12. + mod = await WebAssembly.compile(Buffer.from(llhttpWasmData || require('./llhttp/llhttp-wasm.js'), 'base64')) + } + + return await WebAssembly.instantiate(mod, { + env: { + /* eslint-disable camelcase */ + + wasm_on_url: (p, at, len) => { + /* istanbul ignore next */ + return 0 + }, + wasm_on_status: (p, at, len) => { + assert.strictEqual(currentParser.ptr, p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onStatus(new FastBuffer(currentBufferRef.buffer, start, len)) || 0 + }, + wasm_on_message_begin: (p) => { + assert.strictEqual(currentParser.ptr, p) + return currentParser.onMessageBegin() || 0 + }, + wasm_on_header_field: (p, at, len) => { + assert.strictEqual(currentParser.ptr, p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onHeaderField(new FastBuffer(currentBufferRef.buffer, start, len)) || 0 + }, + wasm_on_header_value: (p, at, len) => { + assert.strictEqual(currentParser.ptr, p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onHeaderValue(new FastBuffer(currentBufferRef.buffer, start, len)) || 0 + }, + wasm_on_headers_complete: (p, statusCode, upgrade, shouldKeepAlive) => { + assert.strictEqual(currentParser.ptr, p) + return currentParser.onHeadersComplete(statusCode, Boolean(upgrade), Boolean(shouldKeepAlive)) || 0 + }, + wasm_on_body: (p, at, len) => { + assert.strictEqual(currentParser.ptr, p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onBody(new FastBuffer(currentBufferRef.buffer, start, len)) || 0 + }, + wasm_on_message_complete: (p) => { + assert.strictEqual(currentParser.ptr, p) + return currentParser.onMessageComplete() || 0 + } + + /* eslint-enable camelcase */ + } + }) +} + +let llhttpInstance = null +let llhttpPromise = lazyllhttp() +llhttpPromise.catch() + +let currentParser = null +let currentBufferRef = null +let currentBufferSize = 0 +let currentBufferPtr = null + +const TIMEOUT_HEADERS = 1 +const TIMEOUT_BODY = 2 +const TIMEOUT_IDLE = 3 + +class Parser { + constructor (client, socket, { exports }) { + assert(Number.isFinite(client[kMaxHeadersSize]) && client[kMaxHeadersSize] > 0) + + this.llhttp = exports + this.ptr = this.llhttp.llhttp_alloc(constants.TYPE.RESPONSE) + this.client = client + this.socket = socket + this.timeout = null + this.timeoutValue = null + this.timeoutType = null + this.statusCode = null + this.statusText = '' + this.upgrade = false + this.headers = [] + this.headersSize = 0 + this.headersMaxSize = client[kMaxHeadersSize] + this.shouldKeepAlive = false + this.paused = false + this.resume = this.resume.bind(this) + + this.bytesRead = 0 + + this.keepAlive = '' + this.contentLength = '' + this.connection = '' + this.maxResponseSize = client[kMaxResponseSize] + } + + setTimeout (value, type) { + this.timeoutType = type + if (value !== this.timeoutValue) { + timers.clearTimeout(this.timeout) + if (value) { + this.timeout = timers.setTimeout(onParserTimeout, value, this) + // istanbul ignore else: only for jest + if (this.timeout.unref) { + this.timeout.unref() + } + } else { + this.timeout = null + } + this.timeoutValue = value + } else if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + } + + resume () { + if (this.socket.destroyed || !this.paused) { + return + } + + assert(this.ptr != null) + assert(currentParser == null) + + this.llhttp.llhttp_resume(this.ptr) + + assert(this.timeoutType === TIMEOUT_BODY) + if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + this.paused = false + this.execute(this.socket.read() || EMPTY_BUF) // Flush parser. + this.readMore() + } + + readMore () { + while (!this.paused && this.ptr) { + const chunk = this.socket.read() + if (chunk === null) { + break + } + this.execute(chunk) + } + } + + execute (data) { + assert(this.ptr != null) + assert(currentParser == null) + assert(!this.paused) + + const { socket, llhttp } = this + + if (data.length > currentBufferSize) { + if (currentBufferPtr) { + llhttp.free(currentBufferPtr) + } + currentBufferSize = Math.ceil(data.length / 4096) * 4096 + currentBufferPtr = llhttp.malloc(currentBufferSize) + } + + new Uint8Array(llhttp.memory.buffer, currentBufferPtr, currentBufferSize).set(data) + + // Call `execute` on the wasm parser. + // We pass the `llhttp_parser` pointer address, the pointer address of buffer view data, + // and finally the length of bytes to parse. + // The return value is an error code or `constants.ERROR.OK`. + try { + let ret + + try { + currentBufferRef = data + currentParser = this + ret = llhttp.llhttp_execute(this.ptr, currentBufferPtr, data.length) + /* eslint-disable-next-line no-useless-catch */ + } catch (err) { + /* istanbul ignore next: difficult to make a test case for */ + throw err + } finally { + currentParser = null + currentBufferRef = null + } + + const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(data.slice(offset)) + } else if (ret === constants.ERROR.PAUSED) { + this.paused = true + socket.unshift(data.slice(offset)) + } else if (ret !== constants.ERROR.OK) { + const ptr = llhttp.llhttp_get_error_reason(this.ptr) + let message = '' + /* istanbul ignore else: difficult to make a test case for */ + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) + message = + 'Response does not match the HTTP/1.1 protocol (' + + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + + ')' + } + throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset)) + } + } catch (err) { + util.destroy(socket, err) + } + } + + destroy () { + assert(this.ptr != null) + assert(currentParser == null) + + this.llhttp.llhttp_free(this.ptr) + this.ptr = null + + timers.clearTimeout(this.timeout) + this.timeout = null + this.timeoutValue = null + this.timeoutType = null + + this.paused = false + } + + onStatus (buf) { + this.statusText = buf.toString() + } + + onMessageBegin () { + const { socket, client } = this + + /* istanbul ignore next: difficult to make a test case for */ + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + if (!request) { + return -1 + } + } + + onHeaderField (buf) { + const len = this.headers.length + + if ((len & 1) === 0) { + this.headers.push(buf) + } else { + this.headers[len - 1] = Buffer.concat([this.headers[len - 1], buf]) + } + + this.trackHeader(buf.length) + } + + onHeaderValue (buf) { + let len = this.headers.length + + if ((len & 1) === 1) { + this.headers.push(buf) + len += 1 + } else { + this.headers[len - 1] = Buffer.concat([this.headers[len - 1], buf]) + } + + const key = this.headers[len - 2] + if (key.length === 10 && key.toString().toLowerCase() === 'keep-alive') { + this.keepAlive += buf.toString() + } else if (key.length === 10 && key.toString().toLowerCase() === 'connection') { + this.connection += buf.toString() + } else if (key.length === 14 && key.toString().toLowerCase() === 'content-length') { + this.contentLength += buf.toString() + } + + this.trackHeader(buf.length) + } + + trackHeader (len) { + this.headersSize += len + if (this.headersSize >= this.headersMaxSize) { + util.destroy(this.socket, new HeadersOverflowError()) + } + } + + onUpgrade (head) { + const { upgrade, client, socket, headers, statusCode } = this + + assert(upgrade) + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + + assert(!socket.destroyed) + assert(socket === client[kSocket]) + assert(!this.paused) + assert(request.upgrade || request.method === 'CONNECT') + + this.statusCode = null + this.statusText = '' + this.shouldKeepAlive = null + + assert(this.headers.length % 2 === 0) + this.headers = [] + this.headersSize = 0 + + socket.unshift(head) + + socket[kParser].destroy() + socket[kParser] = null + + socket[kClient] = null + socket[kError] = null + socket + .removeListener('error', onSocketError) + .removeListener('readable', onSocketReadable) + .removeListener('end', onSocketEnd) + .removeListener('close', onSocketClose) + + client[kSocket] = null + client[kQueue][client[kRunningIdx]++] = null + client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) + + try { + request.onUpgrade(statusCode, headers, socket) + } catch (err) { + util.destroy(socket, err) + } + + resume(client) + } + + onHeadersComplete (statusCode, upgrade, shouldKeepAlive) { + const { client, socket, headers, statusText } = this + + /* istanbul ignore next: difficult to make a test case for */ + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + + /* istanbul ignore next: difficult to make a test case for */ + if (!request) { + return -1 + } + + assert(!this.upgrade) + assert(this.statusCode < 200) + + if (statusCode === 100) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + + /* this can only happen if server is misbehaving */ + if (upgrade && !request.upgrade) { + util.destroy(socket, new SocketError('bad upgrade', util.getSocketInfo(socket))) + return -1 + } + + assert.strictEqual(this.timeoutType, TIMEOUT_HEADERS) + + this.statusCode = statusCode + this.shouldKeepAlive = ( + shouldKeepAlive || + // Override llhttp value which does not allow keepAlive for HEAD. + (request.method === 'HEAD' && !socket[kReset] && this.connection.toLowerCase() === 'keep-alive') + ) + + if (this.statusCode >= 200) { + const bodyTimeout = request.bodyTimeout != null + ? request.bodyTimeout + : client[kBodyTimeout] + this.setTimeout(bodyTimeout, TIMEOUT_BODY) + } else if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + if (request.method === 'CONNECT') { + assert(client[kRunning] === 1) + this.upgrade = true + return 2 + } + + if (upgrade) { + assert(client[kRunning] === 1) + this.upgrade = true + return 2 + } + + assert(this.headers.length % 2 === 0) + this.headers = [] + this.headersSize = 0 + + if (this.shouldKeepAlive && client[kPipelining]) { + const keepAliveTimeout = this.keepAlive ? util.parseKeepAliveTimeout(this.keepAlive) : null + + if (keepAliveTimeout != null) { + const timeout = Math.min( + keepAliveTimeout - client[kKeepAliveTimeoutThreshold], + client[kKeepAliveMaxTimeout] + ) + if (timeout <= 0) { + socket[kReset] = true + } else { + client[kKeepAliveTimeoutValue] = timeout + } + } else { + client[kKeepAliveTimeoutValue] = client[kKeepAliveDefaultTimeout] + } + } else { + // Stop more requests from being dispatched. + socket[kReset] = true + } + + const pause = request.onHeaders(statusCode, headers, this.resume, statusText) === false + + if (request.aborted) { + return -1 + } + + if (request.method === 'HEAD') { + return 1 + } + + if (statusCode < 200) { + return 1 + } + + if (socket[kBlocking]) { + socket[kBlocking] = false + resume(client) + } + + return pause ? constants.ERROR.PAUSED : 0 + } + + onBody (buf) { + const { client, socket, statusCode, maxResponseSize } = this + + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + + assert.strictEqual(this.timeoutType, TIMEOUT_BODY) + if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + assert(statusCode >= 200) + + if (maxResponseSize > -1 && this.bytesRead + buf.length > maxResponseSize) { + util.destroy(socket, new ResponseExceededMaxSizeError()) + return -1 + } + + this.bytesRead += buf.length + + if (request.onData(buf) === false) { + return constants.ERROR.PAUSED + } + } + + onMessageComplete () { + const { client, socket, statusCode, upgrade, headers, contentLength, bytesRead, shouldKeepAlive } = this + + if (socket.destroyed && (!statusCode || shouldKeepAlive)) { + return -1 + } + + if (upgrade) { + return + } + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + + assert(statusCode >= 100) + + this.statusCode = null + this.statusText = '' + this.bytesRead = 0 + this.contentLength = '' + this.keepAlive = '' + this.connection = '' + + assert(this.headers.length % 2 === 0) + this.headers = [] + this.headersSize = 0 + + if (statusCode < 200) { + return + } + + /* istanbul ignore next: should be handled by llhttp? */ + if (request.method !== 'HEAD' && contentLength && bytesRead !== parseInt(contentLength, 10)) { + util.destroy(socket, new ResponseContentLengthMismatchError()) + return -1 + } + + request.onComplete(headers) + + client[kQueue][client[kRunningIdx]++] = null + + if (socket[kWriting]) { + assert.strictEqual(client[kRunning], 0) + // Response completed before request. + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (!shouldKeepAlive) { + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (socket[kReset] && client[kRunning] === 0) { + // Destroy socket once all requests have completed. + // The request at the tail of the pipeline is the one + // that requested reset and no further requests should + // have been queued since then. + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (client[kPipelining] === 1) { + // We must wait a full event loop cycle to reuse this socket to make sure + // that non-spec compliant servers are not closing the connection even if they + // said they won't. + setImmediate(resume, client) + } else { + resume(client) + } + } +} + +function onParserTimeout (parser) { + const { socket, timeoutType, client } = parser + + /* istanbul ignore else */ + if (timeoutType === TIMEOUT_HEADERS) { + if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) { + assert(!parser.paused, 'cannot be paused while waiting for headers') + util.destroy(socket, new HeadersTimeoutError()) + } + } else if (timeoutType === TIMEOUT_BODY) { + if (!parser.paused) { + util.destroy(socket, new BodyTimeoutError()) + } + } else if (timeoutType === TIMEOUT_IDLE) { + assert(client[kRunning] === 0 && client[kKeepAliveTimeoutValue]) + util.destroy(socket, new InformationalError('socket idle timeout')) + } +} + +function onSocketReadable () { + const { [kParser]: parser } = this + if (parser) { + parser.readMore() + } +} + +function onSocketError (err) { + const { [kClient]: client, [kParser]: parser } = this + + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + + if (client[kHTTPConnVersion] !== 'h2') { + // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded + // to the user. + if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so for as a valid response. + parser.onMessageComplete() + return + } + } + + this[kError] = err + + onError(this[kClient], err) +} + +function onError (client, err) { + if ( + client[kRunning] === 0 && + err.code !== 'UND_ERR_INFO' && + err.code !== 'UND_ERR_SOCKET' + ) { + // Error is not caused by running request and not a recoverable + // socket error. + + assert(client[kPendingIdx] === client[kRunningIdx]) + + const requests = client[kQueue].splice(client[kRunningIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + errorRequest(client, request, err) + } + assert(client[kSize] === 0) + } +} + +function onSocketEnd () { + const { [kParser]: parser, [kClient]: client } = this + + if (client[kHTTPConnVersion] !== 'h2') { + if (parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so far as a valid response. + parser.onMessageComplete() + return + } + } + + util.destroy(this, new SocketError('other side closed', util.getSocketInfo(this))) +} + +function onSocketClose () { + const { [kClient]: client, [kParser]: parser } = this + + if (client[kHTTPConnVersion] === 'h1' && parser) { + if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so far as a valid response. + parser.onMessageComplete() + } + + this[kParser].destroy() + this[kParser] = null + } + + const err = this[kError] || new SocketError('closed', util.getSocketInfo(this)) + + client[kSocket] = null + + if (client.destroyed) { + assert(client[kPending] === 0) + + // Fail entire queue. + const requests = client[kQueue].splice(client[kRunningIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + errorRequest(client, request, err) + } + } else if (client[kRunning] > 0 && err.code !== 'UND_ERR_INFO') { + // Fail head of pipeline. + const request = client[kQueue][client[kRunningIdx]] + client[kQueue][client[kRunningIdx]++] = null + + errorRequest(client, request, err) + } + + client[kPendingIdx] = client[kRunningIdx] + + assert(client[kRunning] === 0) + + client.emit('disconnect', client[kUrl], [client], err) + + resume(client) +} + +async function connect (client) { + assert(!client[kConnecting]) + assert(!client[kSocket]) + + let { host, hostname, protocol, port } = client[kUrl] + + // Resolve ipv6 + if (hostname[0] === '[') { + const idx = hostname.indexOf(']') + + assert(idx !== -1) + const ip = hostname.substring(1, idx) + + assert(net.isIP(ip)) + hostname = ip + } + + client[kConnecting] = true + + if (channels.beforeConnect.hasSubscribers) { + channels.beforeConnect.publish({ + connectParams: { + host, + hostname, + protocol, + port, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector] + }) + } + + try { + const socket = await new Promise((resolve, reject) => { + client[kConnector]({ + host, + hostname, + protocol, + port, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, (err, socket) => { + if (err) { + reject(err) + } else { + resolve(socket) + } + }) + }) + + if (client.destroyed) { + util.destroy(socket.on('error', () => {}), new ClientDestroyedError()) + return + } + + client[kConnecting] = false + + assert(socket) + + const isH2 = socket.alpnProtocol === 'h2' + if (isH2) { + if (!h2ExperimentalWarned) { + h2ExperimentalWarned = true + process.emitWarning('H2 support is experimental, expect them to change at any time.', { + code: 'UNDICI-H2' + }) + } + + const session = http2.connect(client[kUrl], { + createConnection: () => socket, + peerMaxConcurrentStreams: client[kHTTP2SessionState].maxConcurrentStreams + }) + + client[kHTTPConnVersion] = 'h2' + session[kClient] = client + session[kSocket] = socket + session.on('error', onHttp2SessionError) + session.on('frameError', onHttp2FrameError) + session.on('end', onHttp2SessionEnd) + session.on('goaway', onHTTP2GoAway) + session.on('close', onSocketClose) + session.unref() + + client[kHTTP2Session] = session + socket[kHTTP2Session] = session + } else { + if (!llhttpInstance) { + llhttpInstance = await llhttpPromise + llhttpPromise = null + } + + socket[kNoRef] = false + socket[kWriting] = false + socket[kReset] = false + socket[kBlocking] = false + socket[kParser] = new Parser(client, socket, llhttpInstance) + } + + socket[kCounter] = 0 + socket[kMaxRequests] = client[kMaxRequests] + socket[kClient] = client + socket[kError] = null + + socket + .on('error', onSocketError) + .on('readable', onSocketReadable) + .on('end', onSocketEnd) + .on('close', onSocketClose) + + client[kSocket] = socket + + if (channels.connected.hasSubscribers) { + channels.connected.publish({ + connectParams: { + host, + hostname, + protocol, + port, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector], + socket + }) + } + client.emit('connect', client[kUrl], [client]) + } catch (err) { + if (client.destroyed) { + return + } + + client[kConnecting] = false + + if (channels.connectError.hasSubscribers) { + channels.connectError.publish({ + connectParams: { + host, + hostname, + protocol, + port, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector], + error: err + }) + } + + if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') { + assert(client[kRunning] === 0) + while (client[kPending] > 0 && client[kQueue][client[kPendingIdx]].servername === client[kServerName]) { + const request = client[kQueue][client[kPendingIdx]++] + errorRequest(client, request, err) + } + } else { + onError(client, err) + } + + client.emit('connectionError', client[kUrl], [client], err) + } + + resume(client) +} + +function emitDrain (client) { + client[kNeedDrain] = 0 + client.emit('drain', client[kUrl], [client]) +} + +function resume (client, sync) { + if (client[kResuming] === 2) { + return + } + + client[kResuming] = 2 + + _resume(client, sync) + client[kResuming] = 0 + + if (client[kRunningIdx] > 256) { + client[kQueue].splice(0, client[kRunningIdx]) + client[kPendingIdx] -= client[kRunningIdx] + client[kRunningIdx] = 0 + } +} + +function _resume (client, sync) { + while (true) { + if (client.destroyed) { + assert(client[kPending] === 0) + return + } + + if (client[kClosedResolve] && !client[kSize]) { + client[kClosedResolve]() + client[kClosedResolve] = null + return + } + + const socket = client[kSocket] + + if (socket && !socket.destroyed && socket.alpnProtocol !== 'h2') { + if (client[kSize] === 0) { + if (!socket[kNoRef] && socket.unref) { + socket.unref() + socket[kNoRef] = true + } + } else if (socket[kNoRef] && socket.ref) { + socket.ref() + socket[kNoRef] = false + } + + if (client[kSize] === 0) { + if (socket[kParser].timeoutType !== TIMEOUT_IDLE) { + socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_IDLE) + } + } else if (client[kRunning] > 0 && socket[kParser].statusCode < 200) { + if (socket[kParser].timeoutType !== TIMEOUT_HEADERS) { + const request = client[kQueue][client[kRunningIdx]] + const headersTimeout = request.headersTimeout != null + ? request.headersTimeout + : client[kHeadersTimeout] + socket[kParser].setTimeout(headersTimeout, TIMEOUT_HEADERS) + } + } + } + + if (client[kBusy]) { + client[kNeedDrain] = 2 + } else if (client[kNeedDrain] === 2) { + if (sync) { + client[kNeedDrain] = 1 + process.nextTick(emitDrain, client) + } else { + emitDrain(client) + } + continue + } + + if (client[kPending] === 0) { + return + } + + if (client[kRunning] >= (client[kPipelining] || 1)) { + return + } + + const request = client[kQueue][client[kPendingIdx]] + + if (client[kUrl].protocol === 'https:' && client[kServerName] !== request.servername) { + if (client[kRunning] > 0) { + return + } + + client[kServerName] = request.servername + + if (socket && socket.servername !== request.servername) { + util.destroy(socket, new InformationalError('servername changed')) + return + } + } + + if (client[kConnecting]) { + return + } + + if (!socket && !client[kHTTP2Session]) { + connect(client) + return + } + + if (socket.destroyed || socket[kWriting] || socket[kReset] || socket[kBlocking]) { + return + } + + if (client[kRunning] > 0 && !request.idempotent) { + // Non-idempotent request cannot be retried. + // Ensure that no other requests are inflight and + // could cause failure. + return + } + + if (client[kRunning] > 0 && (request.upgrade || request.method === 'CONNECT')) { + // Don't dispatch an upgrade until all preceding requests have completed. + // A misbehaving server might upgrade the connection before all pipelined + // request has completed. + return + } + + if (client[kRunning] > 0 && util.bodyLength(request.body) !== 0 && + (util.isStream(request.body) || util.isAsyncIterable(request.body))) { + // Request with stream or iterator body can error while other requests + // are inflight and indirectly error those as well. + // Ensure this doesn't happen by waiting for inflight + // to complete before dispatching. + + // Request with stream or iterator body cannot be retried. + // Ensure that no other requests are inflight and + // could cause failure. + return + } + + if (!request.aborted && write(client, request)) { + client[kPendingIdx]++ + } else { + client[kQueue].splice(client[kPendingIdx], 1) + } + } +} + +// https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2 +function shouldSendContentLength (method) { + return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE' && method !== 'CONNECT' +} + +function write (client, request) { + if (client[kHTTPConnVersion] === 'h2') { + writeH2(client, client[kHTTP2Session], request) + return + } + + const { body, method, path, host, upgrade, headers, blocking, reset } = request + + // https://tools.ietf.org/html/rfc7231#section-4.3.1 + // https://tools.ietf.org/html/rfc7231#section-4.3.2 + // https://tools.ietf.org/html/rfc7231#section-4.3.5 + + // Sending a payload body on a request that does not + // expect it can cause undefined behavior on some + // servers and corrupt connection state. Do not + // re-use the connection for further requests. + + const expectsPayload = ( + method === 'PUT' || + method === 'POST' || + method === 'PATCH' + ) + + if (body && typeof body.read === 'function') { + // Try to read EOF in order to get length. + body.read(0) + } + + const bodyLength = util.bodyLength(body) + + let contentLength = bodyLength + + if (contentLength === null) { + contentLength = request.contentLength + } + + if (contentLength === 0 && !expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD NOT send a Content-Length header field when + // the request message does not contain a payload body and the method + // semantics do not anticipate such a body. + + contentLength = null + } + + // https://github.com/nodejs/undici/issues/2046 + // A user agent may send a Content-Length header with 0 value, this should be allowed. + if (shouldSendContentLength(method) && contentLength > 0 && request.contentLength !== null && request.contentLength !== contentLength) { + if (client[kStrictContentLength]) { + errorRequest(client, request, new RequestContentLengthMismatchError()) + return false + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + const socket = client[kSocket] + + try { + request.onConnect((err) => { + if (request.aborted || request.completed) { + return + } + + errorRequest(client, request, err || new RequestAbortedError()) + + util.destroy(socket, new InformationalError('aborted')) + }) + } catch (err) { + errorRequest(client, request, err) + } + + if (request.aborted) { + return false + } + + if (method === 'HEAD') { + // https://github.com/mcollina/undici/issues/258 + // Close after a HEAD request to interop with misbehaving servers + // that may send a body in the response. + + socket[kReset] = true + } + + if (upgrade || method === 'CONNECT') { + // On CONNECT or upgrade, block pipeline from dispatching further + // requests on this connection. + + socket[kReset] = true + } + + if (reset != null) { + socket[kReset] = reset + } + + if (client[kMaxRequests] && socket[kCounter]++ >= client[kMaxRequests]) { + socket[kReset] = true + } + + if (blocking) { + socket[kBlocking] = true + } + + let header = `${method} ${path} HTTP/1.1\r\n` + + if (typeof host === 'string') { + header += `host: ${host}\r\n` + } else { + header += client[kHostHeader] + } + + if (upgrade) { + header += `connection: upgrade\r\nupgrade: ${upgrade}\r\n` + } else if (client[kPipelining] && !socket[kReset]) { + header += 'connection: keep-alive\r\n' + } else { + header += 'connection: close\r\n' + } + + if (headers) { + header += headers + } + + if (channels.sendHeaders.hasSubscribers) { + channels.sendHeaders.publish({ request, headers: header, socket }) + } + + /* istanbul ignore else: assertion */ + if (!body || bodyLength === 0) { + if (contentLength === 0) { + socket.write(`${header}content-length: 0\r\n\r\n`, 'latin1') + } else { + assert(contentLength === null, 'no body must not have content length') + socket.write(`${header}\r\n`, 'latin1') + } + request.onRequestSent() + } else if (util.isBuffer(body)) { + assert(contentLength === body.byteLength, 'buffer body must have content length') + + socket.cork() + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + socket.write(body) + socket.uncork() + request.onBodySent(body) + request.onRequestSent() + if (!expectsPayload) { + socket[kReset] = true + } + } else if (util.isBlobLike(body)) { + if (typeof body.stream === 'function') { + writeIterable({ body: body.stream(), client, request, socket, contentLength, header, expectsPayload }) + } else { + writeBlob({ body, client, request, socket, contentLength, header, expectsPayload }) + } + } else if (util.isStream(body)) { + writeStream({ body, client, request, socket, contentLength, header, expectsPayload }) + } else if (util.isIterable(body)) { + writeIterable({ body, client, request, socket, contentLength, header, expectsPayload }) + } else { + assert(false) + } + + return true +} + +function writeH2 (client, session, request) { + const { body, method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request + + let headers + if (typeof reqHeaders === 'string') headers = Request[kHTTP2CopyHeaders](reqHeaders.trim()) + else headers = reqHeaders + + if (upgrade) { + errorRequest(client, request, new Error('Upgrade not supported for H2')) + return false + } + + try { + // TODO(HTTP/2): Should we call onConnect immediately or on stream ready event? + request.onConnect((err) => { + if (request.aborted || request.completed) { + return + } + + errorRequest(client, request, err || new RequestAbortedError()) + }) + } catch (err) { + errorRequest(client, request, err) + } + + if (request.aborted) { + return false + } + + /** @type {import('node:http2').ClientHttp2Stream} */ + let stream + const h2State = client[kHTTP2SessionState] + + headers[HTTP2_HEADER_AUTHORITY] = host || client[kHost] + headers[HTTP2_HEADER_METHOD] = method + + if (method === 'CONNECT') { + session.ref() + // we are already connected, streams are pending, first request + // will create a new stream. We trigger a request to create the stream and wait until + // `ready` event is triggered + // We disabled endStream to allow the user to write to the stream + stream = session.request(headers, { endStream: false, signal }) + + if (stream.id && !stream.pending) { + request.onUpgrade(null, null, stream) + ++h2State.openStreams + } else { + stream.once('ready', () => { + request.onUpgrade(null, null, stream) + ++h2State.openStreams + }) + } + + stream.once('close', () => { + h2State.openStreams -= 1 + // TODO(HTTP/2): unref only if current streams count is 0 + if (h2State.openStreams === 0) session.unref() + }) + + return true + } + + // https://tools.ietf.org/html/rfc7540#section-8.3 + // :path and :scheme headers must be omited when sending CONNECT + + headers[HTTP2_HEADER_PATH] = path + headers[HTTP2_HEADER_SCHEME] = 'https' + + // https://tools.ietf.org/html/rfc7231#section-4.3.1 + // https://tools.ietf.org/html/rfc7231#section-4.3.2 + // https://tools.ietf.org/html/rfc7231#section-4.3.5 + + // Sending a payload body on a request that does not + // expect it can cause undefined behavior on some + // servers and corrupt connection state. Do not + // re-use the connection for further requests. + + const expectsPayload = ( + method === 'PUT' || + method === 'POST' || + method === 'PATCH' + ) + + if (body && typeof body.read === 'function') { + // Try to read EOF in order to get length. + body.read(0) + } + + let contentLength = util.bodyLength(body) + + if (contentLength == null) { + contentLength = request.contentLength + } + + if (contentLength === 0 || !expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD NOT send a Content-Length header field when + // the request message does not contain a payload body and the method + // semantics do not anticipate such a body. + + contentLength = null + } + + // https://github.com/nodejs/undici/issues/2046 + // A user agent may send a Content-Length header with 0 value, this should be allowed. + if (shouldSendContentLength(method) && contentLength > 0 && request.contentLength != null && request.contentLength !== contentLength) { + if (client[kStrictContentLength]) { + errorRequest(client, request, new RequestContentLengthMismatchError()) + return false + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + if (contentLength != null) { + assert(body, 'no body must not have content length') + headers[HTTP2_HEADER_CONTENT_LENGTH] = `${contentLength}` + } + + session.ref() + + const shouldEndStream = method === 'GET' || method === 'HEAD' + if (expectContinue) { + headers[HTTP2_HEADER_EXPECT] = '100-continue' + stream = session.request(headers, { endStream: shouldEndStream, signal }) + + stream.once('continue', writeBodyH2) + } else { + stream = session.request(headers, { + endStream: shouldEndStream, + signal + }) + writeBodyH2() + } + + // Increment counter as we have new several streams open + ++h2State.openStreams + + stream.once('response', headers => { + const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers + + if (request.onHeaders(Number(statusCode), realHeaders, stream.resume.bind(stream), '') === false) { + stream.pause() + } + }) + + stream.once('end', () => { + request.onComplete([]) + }) + + stream.on('data', (chunk) => { + if (request.onData(chunk) === false) { + stream.pause() + } + }) + + stream.once('close', () => { + h2State.openStreams -= 1 + // TODO(HTTP/2): unref only if current streams count is 0 + if (h2State.openStreams === 0) { + session.unref() + } + }) + + stream.once('error', function (err) { + if (client[kHTTP2Session] && !client[kHTTP2Session].destroyed && !this.closed && !this.destroyed) { + h2State.streams -= 1 + util.destroy(stream, err) + } + }) + + stream.once('frameError', (type, code) => { + const err = new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`) + errorRequest(client, request, err) + + if (client[kHTTP2Session] && !client[kHTTP2Session].destroyed && !this.closed && !this.destroyed) { + h2State.streams -= 1 + util.destroy(stream, err) + } + }) + + // stream.on('aborted', () => { + // // TODO(HTTP/2): Support aborted + // }) + + // stream.on('timeout', () => { + // // TODO(HTTP/2): Support timeout + // }) + + // stream.on('push', headers => { + // // TODO(HTTP/2): Suppor push + // }) + + // stream.on('trailers', headers => { + // // TODO(HTTP/2): Support trailers + // }) + + return true + + function writeBodyH2 () { + /* istanbul ignore else: assertion */ + if (!body) { + request.onRequestSent() + } else if (util.isBuffer(body)) { + assert(contentLength === body.byteLength, 'buffer body must have content length') + stream.cork() + stream.write(body) + stream.uncork() + stream.end() + request.onBodySent(body) + request.onRequestSent() + } else if (util.isBlobLike(body)) { + if (typeof body.stream === 'function') { + writeIterable({ + client, + request, + contentLength, + h2stream: stream, + expectsPayload, + body: body.stream(), + socket: client[kSocket], + header: '' + }) + } else { + writeBlob({ + body, + client, + request, + contentLength, + expectsPayload, + h2stream: stream, + header: '', + socket: client[kSocket] + }) + } + } else if (util.isStream(body)) { + writeStream({ + body, + client, + request, + contentLength, + expectsPayload, + socket: client[kSocket], + h2stream: stream, + header: '' + }) + } else if (util.isIterable(body)) { + writeIterable({ + body, + client, + request, + contentLength, + expectsPayload, + header: '', + h2stream: stream, + socket: client[kSocket] + }) + } else { + assert(false) + } + } +} + +function writeStream ({ h2stream, body, client, request, socket, contentLength, header, expectsPayload }) { + assert(contentLength !== 0 || client[kRunning] === 0, 'stream body cannot be pipelined') + + if (client[kHTTPConnVersion] === 'h2') { + // For HTTP/2, is enough to pipe the stream + const pipe = pipeline( + body, + h2stream, + (err) => { + if (err) { + util.destroy(body, err) + util.destroy(h2stream, err) + } else { + request.onRequestSent() + } + } + ) + + pipe.on('data', onPipeData) + pipe.once('end', () => { + pipe.removeListener('data', onPipeData) + util.destroy(pipe) + }) + + function onPipeData (chunk) { + request.onBodySent(chunk) + } + + return + } + + let finished = false + + const writer = new AsyncWriter({ socket, request, contentLength, client, expectsPayload, header }) + + const onData = function (chunk) { + if (finished) { + return + } + + try { + if (!writer.write(chunk) && this.pause) { + this.pause() + } + } catch (err) { + util.destroy(this, err) + } + } + const onDrain = function () { + if (finished) { + return + } + + if (body.resume) { + body.resume() + } + } + const onAbort = function () { + if (finished) { + return + } + const err = new RequestAbortedError() + queueMicrotask(() => onFinished(err)) + } + const onFinished = function (err) { + if (finished) { + return + } + + finished = true + + assert(socket.destroyed || (socket[kWriting] && client[kRunning] <= 1)) + + socket + .off('drain', onDrain) + .off('error', onFinished) + + body + .removeListener('data', onData) + .removeListener('end', onFinished) + .removeListener('error', onFinished) + .removeListener('close', onAbort) + + if (!err) { + try { + writer.end() + } catch (er) { + err = er + } + } + + writer.destroy(err) + + if (err && (err.code !== 'UND_ERR_INFO' || err.message !== 'reset')) { + util.destroy(body, err) + } else { + util.destroy(body) + } + } + + body + .on('data', onData) + .on('end', onFinished) + .on('error', onFinished) + .on('close', onAbort) + + if (body.resume) { + body.resume() + } + + socket + .on('drain', onDrain) + .on('error', onFinished) +} + +async function writeBlob ({ h2stream, body, client, request, socket, contentLength, header, expectsPayload }) { + assert(contentLength === body.size, 'blob body must have content length') + + const isH2 = client[kHTTPConnVersion] === 'h2' + try { + if (contentLength != null && contentLength !== body.size) { + throw new RequestContentLengthMismatchError() + } + + const buffer = Buffer.from(await body.arrayBuffer()) + + if (isH2) { + h2stream.cork() + h2stream.write(buffer) + h2stream.uncork() + } else { + socket.cork() + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + socket.write(buffer) + socket.uncork() + } + + request.onBodySent(buffer) + request.onRequestSent() + + if (!expectsPayload) { + socket[kReset] = true + } + + resume(client) + } catch (err) { + util.destroy(isH2 ? h2stream : socket, err) + } +} + +async function writeIterable ({ h2stream, body, client, request, socket, contentLength, header, expectsPayload }) { + assert(contentLength !== 0 || client[kRunning] === 0, 'iterator body cannot be pipelined') + + let callback = null + function onDrain () { + if (callback) { + const cb = callback + callback = null + cb() + } + } + + const waitForDrain = () => new Promise((resolve, reject) => { + assert(callback === null) + + if (socket[kError]) { + reject(socket[kError]) + } else { + callback = resolve + } + }) + + if (client[kHTTPConnVersion] === 'h2') { + h2stream + .on('close', onDrain) + .on('drain', onDrain) + + try { + // It's up to the user to somehow abort the async iterable. + for await (const chunk of body) { + if (socket[kError]) { + throw socket[kError] + } + + const res = h2stream.write(chunk) + request.onBodySent(chunk) + if (!res) { + await waitForDrain() + } + } + } catch (err) { + h2stream.destroy(err) + } finally { + request.onRequestSent() + h2stream.end() + h2stream + .off('close', onDrain) + .off('drain', onDrain) + } + + return + } + + socket + .on('close', onDrain) + .on('drain', onDrain) + + const writer = new AsyncWriter({ socket, request, contentLength, client, expectsPayload, header }) + try { + // It's up to the user to somehow abort the async iterable. + for await (const chunk of body) { + if (socket[kError]) { + throw socket[kError] + } + + if (!writer.write(chunk)) { + await waitForDrain() + } + } + + writer.end() + } catch (err) { + writer.destroy(err) + } finally { + socket + .off('close', onDrain) + .off('drain', onDrain) + } +} + +class AsyncWriter { + constructor ({ socket, request, contentLength, client, expectsPayload, header }) { + this.socket = socket + this.request = request + this.contentLength = contentLength + this.client = client + this.bytesWritten = 0 + this.expectsPayload = expectsPayload + this.header = header + + socket[kWriting] = true + } + + write (chunk) { + const { socket, request, contentLength, client, bytesWritten, expectsPayload, header } = this + + if (socket[kError]) { + throw socket[kError] + } + + if (socket.destroyed) { + return false + } + + const len = Buffer.byteLength(chunk) + if (!len) { + return true + } + + // We should defer writing chunks. + if (contentLength !== null && bytesWritten + len > contentLength) { + if (client[kStrictContentLength]) { + throw new RequestContentLengthMismatchError() + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + socket.cork() + + if (bytesWritten === 0) { + if (!expectsPayload) { + socket[kReset] = true + } + + if (contentLength === null) { + socket.write(`${header}transfer-encoding: chunked\r\n`, 'latin1') + } else { + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + } + } + + if (contentLength === null) { + socket.write(`\r\n${len.toString(16)}\r\n`, 'latin1') + } + + this.bytesWritten += len + + const ret = socket.write(chunk) + + socket.uncork() + + request.onBodySent(chunk) + + if (!ret) { + if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) { + // istanbul ignore else: only for jest + if (socket[kParser].timeout.refresh) { + socket[kParser].timeout.refresh() + } + } + } + + return ret + } + + end () { + const { socket, contentLength, client, bytesWritten, expectsPayload, header, request } = this + request.onRequestSent() + + socket[kWriting] = false + + if (socket[kError]) { + throw socket[kError] + } + + if (socket.destroyed) { + return + } + + if (bytesWritten === 0) { + if (expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD send a Content-Length in a request message when + // no Transfer-Encoding is sent and the request method defines a meaning + // for an enclosed payload body. + + socket.write(`${header}content-length: 0\r\n\r\n`, 'latin1') + } else { + socket.write(`${header}\r\n`, 'latin1') + } + } else if (contentLength === null) { + socket.write('\r\n0\r\n\r\n', 'latin1') + } + + if (contentLength !== null && bytesWritten !== contentLength) { + if (client[kStrictContentLength]) { + throw new RequestContentLengthMismatchError() + } else { + process.emitWarning(new RequestContentLengthMismatchError()) + } + } + + if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) { + // istanbul ignore else: only for jest + if (socket[kParser].timeout.refresh) { + socket[kParser].timeout.refresh() + } + } + + resume(client) + } + + destroy (err) { + const { socket, client } = this + + socket[kWriting] = false + + if (err) { + assert(client[kRunning] <= 1, 'pipeline should only contain this request') + util.destroy(socket, err) + } + } +} + +function errorRequest (client, request, err) { + try { + request.onError(err) + assert(request.aborted) + } catch (err) { + client.emit('error', err) + } +} + +module.exports = Client diff --git a/lib/compat/dispatcher-weakref.js b/lib/compat/dispatcher-weakref.js new file mode 100644 index 0000000..8cb99e2 --- /dev/null +++ b/lib/compat/dispatcher-weakref.js @@ -0,0 +1,48 @@ +'use strict' + +/* istanbul ignore file: only for Node 12 */ + +const { kConnected, kSize } = require('../core/symbols') + +class CompatWeakRef { + constructor (value) { + this.value = value + } + + deref () { + return this.value[kConnected] === 0 && this.value[kSize] === 0 + ? undefined + : this.value + } +} + +class CompatFinalizer { + constructor (finalizer) { + this.finalizer = finalizer + } + + register (dispatcher, key) { + if (dispatcher.on) { + dispatcher.on('disconnect', () => { + if (dispatcher[kConnected] === 0 && dispatcher[kSize] === 0) { + this.finalizer(key) + } + }) + } + } +} + +module.exports = function () { + // FIXME: remove workaround when the Node bug is fixed + // https://github.com/nodejs/node/issues/49344#issuecomment-1741776308 + if (process.env.NODE_V8_COVERAGE) { + return { + WeakRef: CompatWeakRef, + FinalizationRegistry: CompatFinalizer + } + } + return { + WeakRef: global.WeakRef || CompatWeakRef, + FinalizationRegistry: global.FinalizationRegistry || CompatFinalizer + } +} diff --git a/lib/cookies/constants.js b/lib/cookies/constants.js new file mode 100644 index 0000000..85f1fec --- /dev/null +++ b/lib/cookies/constants.js @@ -0,0 +1,12 @@ +'use strict' + +// https://wicg.github.io/cookie-store/#cookie-maximum-attribute-value-size +const maxAttributeValueSize = 1024 + +// https://wicg.github.io/cookie-store/#cookie-maximum-name-value-pair-size +const maxNameValuePairSize = 4096 + +module.exports = { + maxAttributeValueSize, + maxNameValuePairSize +} diff --git a/lib/cookies/index.js b/lib/cookies/index.js new file mode 100644 index 0000000..c9c1f28 --- /dev/null +++ b/lib/cookies/index.js @@ -0,0 +1,184 @@ +'use strict' + +const { parseSetCookie } = require('./parse') +const { stringify, getHeadersList } = require('./util') +const { webidl } = require('../fetch/webidl') +const { Headers } = require('../fetch/headers') + +/** + * @typedef {Object} Cookie + * @property {string} name + * @property {string} value + * @property {Date|number|undefined} expires + * @property {number|undefined} maxAge + * @property {string|undefined} domain + * @property {string|undefined} path + * @property {boolean|undefined} secure + * @property {boolean|undefined} httpOnly + * @property {'Strict'|'Lax'|'None'} sameSite + * @property {string[]} unparsed + */ + +/** + * @param {Headers} headers + * @returns {Record} + */ +function getCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, { header: 'getCookies' }) + + webidl.brandCheck(headers, Headers, { strict: false }) + + const cookie = headers.get('cookie') + const out = {} + + if (!cookie) { + return out + } + + for (const piece of cookie.split(';')) { + const [name, ...value] = piece.split('=') + + out[name.trim()] = value.join('=') + } + + return out +} + +/** + * @param {Headers} headers + * @param {string} name + * @param {{ path?: string, domain?: string }|undefined} attributes + * @returns {void} + */ +function deleteCookie (headers, name, attributes) { + webidl.argumentLengthCheck(arguments, 2, { header: 'deleteCookie' }) + + webidl.brandCheck(headers, Headers, { strict: false }) + + name = webidl.converters.DOMString(name) + attributes = webidl.converters.DeleteCookieAttributes(attributes) + + // Matches behavior of + // https://github.com/denoland/deno_std/blob/63827b16330b82489a04614027c33b7904e08be5/http/cookie.ts#L278 + setCookie(headers, { + name, + value: '', + expires: new Date(0), + ...attributes + }) +} + +/** + * @param {Headers} headers + * @returns {Cookie[]} + */ +function getSetCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, { header: 'getSetCookies' }) + + webidl.brandCheck(headers, Headers, { strict: false }) + + const cookies = getHeadersList(headers).cookies + + if (!cookies) { + return [] + } + + // In older versions of undici, cookies is a list of name:value. + return cookies.map((pair) => parseSetCookie(Array.isArray(pair) ? pair[1] : pair)) +} + +/** + * @param {Headers} headers + * @param {Cookie} cookie + * @returns {void} + */ +function setCookie (headers, cookie) { + webidl.argumentLengthCheck(arguments, 2, { header: 'setCookie' }) + + webidl.brandCheck(headers, Headers, { strict: false }) + + cookie = webidl.converters.Cookie(cookie) + + const str = stringify(cookie) + + if (str) { + headers.append('Set-Cookie', stringify(cookie)) + } +} + +webidl.converters.DeleteCookieAttributes = webidl.dictionaryConverter([ + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: null + } +]) + +webidl.converters.Cookie = webidl.dictionaryConverter([ + { + converter: webidl.converters.DOMString, + key: 'name' + }, + { + converter: webidl.converters.DOMString, + key: 'value' + }, + { + converter: webidl.nullableConverter((value) => { + if (typeof value === 'number') { + return webidl.converters['unsigned long long'](value) + } + + return new Date(value) + }), + key: 'expires', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters['long long']), + key: 'maxAge', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'secure', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'httpOnly', + defaultValue: null + }, + { + converter: webidl.converters.USVString, + key: 'sameSite', + allowedValues: ['Strict', 'Lax', 'None'] + }, + { + converter: webidl.sequenceConverter(webidl.converters.DOMString), + key: 'unparsed', + defaultValue: [] + } +]) + +module.exports = { + getCookies, + deleteCookie, + getSetCookies, + setCookie +} diff --git a/lib/cookies/parse.js b/lib/cookies/parse.js new file mode 100644 index 0000000..aae2750 --- /dev/null +++ b/lib/cookies/parse.js @@ -0,0 +1,317 @@ +'use strict' + +const { maxNameValuePairSize, maxAttributeValueSize } = require('./constants') +const { isCTLExcludingHtab } = require('./util') +const { collectASequenceOfCodePointsFast } = require('../fetch/dataURL') +const assert = require('assert') + +/** + * @description Parses the field-value attributes of a set-cookie header string. + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} header + * @returns if the header is invalid, null will be returned + */ +function parseSetCookie (header) { + // 1. If the set-cookie-string contains a %x00-08 / %x0A-1F / %x7F + // character (CTL characters excluding HTAB): Abort these steps and + // ignore the set-cookie-string entirely. + if (isCTLExcludingHtab(header)) { + return null + } + + let nameValuePair = '' + let unparsedAttributes = '' + let name = '' + let value = '' + + // 2. If the set-cookie-string contains a %x3B (";") character: + if (header.includes(';')) { + // 1. The name-value-pair string consists of the characters up to, + // but not including, the first %x3B (";"), and the unparsed- + // attributes consist of the remainder of the set-cookie-string + // (including the %x3B (";") in question). + const position = { position: 0 } + + nameValuePair = collectASequenceOfCodePointsFast(';', header, position) + unparsedAttributes = header.slice(position.position) + } else { + // Otherwise: + + // 1. The name-value-pair string consists of all the characters + // contained in the set-cookie-string, and the unparsed- + // attributes is the empty string. + nameValuePair = header + } + + // 3. If the name-value-pair string lacks a %x3D ("=") character, then + // the name string is empty, and the value string is the value of + // name-value-pair. + if (!nameValuePair.includes('=')) { + value = nameValuePair + } else { + // Otherwise, the name string consists of the characters up to, but + // not including, the first %x3D ("=") character, and the (possibly + // empty) value string consists of the characters after the first + // %x3D ("=") character. + const position = { position: 0 } + name = collectASequenceOfCodePointsFast( + '=', + nameValuePair, + position + ) + value = nameValuePair.slice(position.position + 1) + } + + // 4. Remove any leading or trailing WSP characters from the name + // string and the value string. + name = name.trim() + value = value.trim() + + // 5. If the sum of the lengths of the name string and the value string + // is more than 4096 octets, abort these steps and ignore the set- + // cookie-string entirely. + if (name.length + value.length > maxNameValuePairSize) { + return null + } + + // 6. The cookie-name is the name string, and the cookie-value is the + // value string. + return { + name, value, ...parseUnparsedAttributes(unparsedAttributes) + } +} + +/** + * Parses the remaining attributes of a set-cookie header + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} unparsedAttributes + * @param {[Object.]={}} cookieAttributeList + */ +function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) { + // 1. If the unparsed-attributes string is empty, skip the rest of + // these steps. + if (unparsedAttributes.length === 0) { + return cookieAttributeList + } + + // 2. Discard the first character of the unparsed-attributes (which + // will be a %x3B (";") character). + assert(unparsedAttributes[0] === ';') + unparsedAttributes = unparsedAttributes.slice(1) + + let cookieAv = '' + + // 3. If the remaining unparsed-attributes contains a %x3B (";") + // character: + if (unparsedAttributes.includes(';')) { + // 1. Consume the characters of the unparsed-attributes up to, but + // not including, the first %x3B (";") character. + cookieAv = collectASequenceOfCodePointsFast( + ';', + unparsedAttributes, + { position: 0 } + ) + unparsedAttributes = unparsedAttributes.slice(cookieAv.length) + } else { + // Otherwise: + + // 1. Consume the remainder of the unparsed-attributes. + cookieAv = unparsedAttributes + unparsedAttributes = '' + } + + // Let the cookie-av string be the characters consumed in this step. + + let attributeName = '' + let attributeValue = '' + + // 4. If the cookie-av string contains a %x3D ("=") character: + if (cookieAv.includes('=')) { + // 1. The (possibly empty) attribute-name string consists of the + // characters up to, but not including, the first %x3D ("=") + // character, and the (possibly empty) attribute-value string + // consists of the characters after the first %x3D ("=") + // character. + const position = { position: 0 } + + attributeName = collectASequenceOfCodePointsFast( + '=', + cookieAv, + position + ) + attributeValue = cookieAv.slice(position.position + 1) + } else { + // Otherwise: + + // 1. The attribute-name string consists of the entire cookie-av + // string, and the attribute-value string is empty. + attributeName = cookieAv + } + + // 5. Remove any leading or trailing WSP characters from the attribute- + // name string and the attribute-value string. + attributeName = attributeName.trim() + attributeValue = attributeValue.trim() + + // 6. If the attribute-value is longer than 1024 octets, ignore the + // cookie-av string and return to Step 1 of this algorithm. + if (attributeValue.length > maxAttributeValueSize) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 7. Process the attribute-name and attribute-value according to the + // requirements in the following subsections. (Notice that + // attributes with unrecognized attribute-names are ignored.) + const attributeNameLowercase = attributeName.toLowerCase() + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1 + // If the attribute-name case-insensitively matches the string + // "Expires", the user agent MUST process the cookie-av as follows. + if (attributeNameLowercase === 'expires') { + // 1. Let the expiry-time be the result of parsing the attribute-value + // as cookie-date (see Section 5.1.1). + const expiryTime = new Date(attributeValue) + + // 2. If the attribute-value failed to parse as a cookie date, ignore + // the cookie-av. + + cookieAttributeList.expires = expiryTime + } else if (attributeNameLowercase === 'max-age') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2 + // If the attribute-name case-insensitively matches the string "Max- + // Age", the user agent MUST process the cookie-av as follows. + + // 1. If the first character of the attribute-value is not a DIGIT or a + // "-" character, ignore the cookie-av. + const charCode = attributeValue.charCodeAt(0) + + if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 2. If the remainder of attribute-value contains a non-DIGIT + // character, ignore the cookie-av. + if (!/^\d+$/.test(attributeValue)) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 3. Let delta-seconds be the attribute-value converted to an integer. + const deltaSeconds = Number(attributeValue) + + // 4. Let cookie-age-limit be the maximum age of the cookie (which + // SHOULD be 400 days or less, see Section 4.1.2.2). + + // 5. Set delta-seconds to the smaller of its present value and cookie- + // age-limit. + // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs) + + // 6. If delta-seconds is less than or equal to zero (0), let expiry- + // time be the earliest representable date and time. Otherwise, let + // the expiry-time be the current date and time plus delta-seconds + // seconds. + // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds + + // 7. Append an attribute to the cookie-attribute-list with an + // attribute-name of Max-Age and an attribute-value of expiry-time. + cookieAttributeList.maxAge = deltaSeconds + } else if (attributeNameLowercase === 'domain') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.3 + // If the attribute-name case-insensitively matches the string "Domain", + // the user agent MUST process the cookie-av as follows. + + // 1. Let cookie-domain be the attribute-value. + let cookieDomain = attributeValue + + // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be + // cookie-domain without its leading %x2E ("."). + if (cookieDomain[0] === '.') { + cookieDomain = cookieDomain.slice(1) + } + + // 3. Convert the cookie-domain to lower case. + cookieDomain = cookieDomain.toLowerCase() + + // 4. Append an attribute to the cookie-attribute-list with an + // attribute-name of Domain and an attribute-value of cookie-domain. + cookieAttributeList.domain = cookieDomain + } else if (attributeNameLowercase === 'path') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.4 + // If the attribute-name case-insensitively matches the string "Path", + // the user agent MUST process the cookie-av as follows. + + // 1. If the attribute-value is empty or if the first character of the + // attribute-value is not %x2F ("/"): + let cookiePath = '' + if (attributeValue.length === 0 || attributeValue[0] !== '/') { + // 1. Let cookie-path be the default-path. + cookiePath = '/' + } else { + // Otherwise: + + // 1. Let cookie-path be the attribute-value. + cookiePath = attributeValue + } + + // 2. Append an attribute to the cookie-attribute-list with an + // attribute-name of Path and an attribute-value of cookie-path. + cookieAttributeList.path = cookiePath + } else if (attributeNameLowercase === 'secure') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.5 + // If the attribute-name case-insensitively matches the string "Secure", + // the user agent MUST append an attribute to the cookie-attribute-list + // with an attribute-name of Secure and an empty attribute-value. + + cookieAttributeList.secure = true + } else if (attributeNameLowercase === 'httponly') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.6 + // If the attribute-name case-insensitively matches the string + // "HttpOnly", the user agent MUST append an attribute to the cookie- + // attribute-list with an attribute-name of HttpOnly and an empty + // attribute-value. + + cookieAttributeList.httpOnly = true + } else if (attributeNameLowercase === 'samesite') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7 + // If the attribute-name case-insensitively matches the string + // "SameSite", the user agent MUST process the cookie-av as follows: + + // 1. Let enforcement be "Default". + let enforcement = 'Default' + + const attributeValueLowercase = attributeValue.toLowerCase() + // 2. If cookie-av's attribute-value is a case-insensitive match for + // "None", set enforcement to "None". + if (attributeValueLowercase.includes('none')) { + enforcement = 'None' + } + + // 3. If cookie-av's attribute-value is a case-insensitive match for + // "Strict", set enforcement to "Strict". + if (attributeValueLowercase.includes('strict')) { + enforcement = 'Strict' + } + + // 4. If cookie-av's attribute-value is a case-insensitive match for + // "Lax", set enforcement to "Lax". + if (attributeValueLowercase.includes('lax')) { + enforcement = 'Lax' + } + + // 5. Append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of + // enforcement. + cookieAttributeList.sameSite = enforcement + } else { + cookieAttributeList.unparsed ??= [] + + cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`) + } + + // 8. Return to Step 1 of this algorithm. + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) +} + +module.exports = { + parseSetCookie, + parseUnparsedAttributes +} diff --git a/lib/cookies/util.js b/lib/cookies/util.js new file mode 100644 index 0000000..2290329 --- /dev/null +++ b/lib/cookies/util.js @@ -0,0 +1,291 @@ +'use strict' + +const assert = require('assert') +const { kHeadersList } = require('../core/symbols') + +function isCTLExcludingHtab (value) { + if (value.length === 0) { + return false + } + + for (const char of value) { + const code = char.charCodeAt(0) + + if ( + (code >= 0x00 || code <= 0x08) || + (code >= 0x0A || code <= 0x1F) || + code === 0x7F + ) { + return false + } + } +} + +/** + CHAR = + token = 1* + separators = "(" | ")" | "<" | ">" | "@" + | "," | ";" | ":" | "\" | <"> + | "/" | "[" | "]" | "?" | "=" + | "{" | "}" | SP | HT + * @param {string} name + */ +function validateCookieName (name) { + for (const char of name) { + const code = char.charCodeAt(0) + + if ( + (code <= 0x20 || code > 0x7F) || + char === '(' || + char === ')' || + char === '>' || + char === '<' || + char === '@' || + char === ',' || + char === ';' || + char === ':' || + char === '\\' || + char === '"' || + char === '/' || + char === '[' || + char === ']' || + char === '?' || + char === '=' || + char === '{' || + char === '}' + ) { + throw new Error('Invalid cookie name') + } + } +} + +/** + cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) + cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + ; US-ASCII characters excluding CTLs, + ; whitespace DQUOTE, comma, semicolon, + ; and backslash + * @param {string} value + */ +function validateCookieValue (value) { + for (const char of value) { + const code = char.charCodeAt(0) + + if ( + code < 0x21 || // exclude CTLs (0-31) + code === 0x22 || + code === 0x2C || + code === 0x3B || + code === 0x5C || + code > 0x7E // non-ascii + ) { + throw new Error('Invalid header value') + } + } +} + +/** + * path-value = + * @param {string} path + */ +function validateCookiePath (path) { + for (const char of path) { + const code = char.charCodeAt(0) + + if (code < 0x21 || char === ';') { + throw new Error('Invalid cookie path') + } + } +} + +/** + * I have no idea why these values aren't allowed to be honest, + * but Deno tests these. - Khafra + * @param {string} domain + */ +function validateCookieDomain (domain) { + if ( + domain.startsWith('-') || + domain.endsWith('.') || + domain.endsWith('-') + ) { + throw new Error('Invalid cookie domain') + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1 + * @param {number|Date} date + IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT + ; fixed length/zone/capitalization subset of the format + ; see Section 3.3 of [RFC5322] + + day-name = %x4D.6F.6E ; "Mon", case-sensitive + / %x54.75.65 ; "Tue", case-sensitive + / %x57.65.64 ; "Wed", case-sensitive + / %x54.68.75 ; "Thu", case-sensitive + / %x46.72.69 ; "Fri", case-sensitive + / %x53.61.74 ; "Sat", case-sensitive + / %x53.75.6E ; "Sun", case-sensitive + date1 = day SP month SP year + ; e.g., 02 Jun 1982 + + day = 2DIGIT + month = %x4A.61.6E ; "Jan", case-sensitive + / %x46.65.62 ; "Feb", case-sensitive + / %x4D.61.72 ; "Mar", case-sensitive + / %x41.70.72 ; "Apr", case-sensitive + / %x4D.61.79 ; "May", case-sensitive + / %x4A.75.6E ; "Jun", case-sensitive + / %x4A.75.6C ; "Jul", case-sensitive + / %x41.75.67 ; "Aug", case-sensitive + / %x53.65.70 ; "Sep", case-sensitive + / %x4F.63.74 ; "Oct", case-sensitive + / %x4E.6F.76 ; "Nov", case-sensitive + / %x44.65.63 ; "Dec", case-sensitive + year = 4DIGIT + + GMT = %x47.4D.54 ; "GMT", case-sensitive + + time-of-day = hour ":" minute ":" second + ; 00:00:00 - 23:59:60 (leap second) + + hour = 2DIGIT + minute = 2DIGIT + second = 2DIGIT + */ +function toIMFDate (date) { + if (typeof date === 'number') { + date = new Date(date) + } + + const days = [ + 'Sun', 'Mon', 'Tue', 'Wed', + 'Thu', 'Fri', 'Sat' + ] + + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ] + + const dayName = days[date.getUTCDay()] + const day = date.getUTCDate().toString().padStart(2, '0') + const month = months[date.getUTCMonth()] + const year = date.getUTCFullYear() + const hour = date.getUTCHours().toString().padStart(2, '0') + const minute = date.getUTCMinutes().toString().padStart(2, '0') + const second = date.getUTCSeconds().toString().padStart(2, '0') + + return `${dayName}, ${day} ${month} ${year} ${hour}:${minute}:${second} GMT` +} + +/** + max-age-av = "Max-Age=" non-zero-digit *DIGIT + ; In practice, both expires-av and max-age-av + ; are limited to dates representable by the + ; user agent. + * @param {number} maxAge + */ +function validateCookieMaxAge (maxAge) { + if (maxAge < 0) { + throw new Error('Invalid cookie max-age') + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1 + * @param {import('./index').Cookie} cookie + */ +function stringify (cookie) { + if (cookie.name.length === 0) { + return null + } + + validateCookieName(cookie.name) + validateCookieValue(cookie.value) + + const out = [`${cookie.name}=${cookie.value}`] + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1 + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.2 + if (cookie.name.startsWith('__Secure-')) { + cookie.secure = true + } + + if (cookie.name.startsWith('__Host-')) { + cookie.secure = true + cookie.domain = null + cookie.path = '/' + } + + if (cookie.secure) { + out.push('Secure') + } + + if (cookie.httpOnly) { + out.push('HttpOnly') + } + + if (typeof cookie.maxAge === 'number') { + validateCookieMaxAge(cookie.maxAge) + out.push(`Max-Age=${cookie.maxAge}`) + } + + if (cookie.domain) { + validateCookieDomain(cookie.domain) + out.push(`Domain=${cookie.domain}`) + } + + if (cookie.path) { + validateCookiePath(cookie.path) + out.push(`Path=${cookie.path}`) + } + + if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') { + out.push(`Expires=${toIMFDate(cookie.expires)}`) + } + + if (cookie.sameSite) { + out.push(`SameSite=${cookie.sameSite}`) + } + + for (const part of cookie.unparsed) { + if (!part.includes('=')) { + throw new Error('Invalid unparsed') + } + + const [key, ...value] = part.split('=') + + out.push(`${key.trim()}=${value.join('=')}`) + } + + return out.join('; ') +} + +let kHeadersListNode + +function getHeadersList (headers) { + if (headers[kHeadersList]) { + return headers[kHeadersList] + } + + if (!kHeadersListNode) { + kHeadersListNode = Object.getOwnPropertySymbols(headers).find( + (symbol) => symbol.description === 'headers list' + ) + + assert(kHeadersListNode, 'Headers cannot be parsed') + } + + const headersList = headers[kHeadersListNode] + assert(headersList) + + return headersList +} + +module.exports = { + isCTLExcludingHtab, + stringify, + getHeadersList +} diff --git a/lib/core/connect.js b/lib/core/connect.js new file mode 100644 index 0000000..3309117 --- /dev/null +++ b/lib/core/connect.js @@ -0,0 +1,189 @@ +'use strict' + +const net = require('net') +const assert = require('assert') +const util = require('./util') +const { InvalidArgumentError, ConnectTimeoutError } = require('./errors') + +let tls // include tls conditionally since it is not always available + +// TODO: session re-use does not wait for the first +// connection to resolve the session and might therefore +// resolve the same servername multiple times even when +// re-use is enabled. + +let SessionCache +// FIXME: remove workaround when the Node bug is fixed +// https://github.com/nodejs/node/issues/49344#issuecomment-1741776308 +if (global.FinalizationRegistry && !process.env.NODE_V8_COVERAGE) { + SessionCache = class WeakSessionCache { + constructor (maxCachedSessions) { + this._maxCachedSessions = maxCachedSessions + this._sessionCache = new Map() + this._sessionRegistry = new global.FinalizationRegistry((key) => { + if (this._sessionCache.size < this._maxCachedSessions) { + return + } + + const ref = this._sessionCache.get(key) + if (ref !== undefined && ref.deref() === undefined) { + this._sessionCache.delete(key) + } + }) + } + + get (sessionKey) { + const ref = this._sessionCache.get(sessionKey) + return ref ? ref.deref() : null + } + + set (sessionKey, session) { + if (this._maxCachedSessions === 0) { + return + } + + this._sessionCache.set(sessionKey, new WeakRef(session)) + this._sessionRegistry.register(session, sessionKey) + } + } +} else { + SessionCache = class SimpleSessionCache { + constructor (maxCachedSessions) { + this._maxCachedSessions = maxCachedSessions + this._sessionCache = new Map() + } + + get (sessionKey) { + return this._sessionCache.get(sessionKey) + } + + set (sessionKey, session) { + if (this._maxCachedSessions === 0) { + return + } + + if (this._sessionCache.size >= this._maxCachedSessions) { + // remove the oldest session + const { value: oldestKey } = this._sessionCache.keys().next() + this._sessionCache.delete(oldestKey) + } + + this._sessionCache.set(sessionKey, session) + } + } +} + +function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, ...opts }) { + if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) { + throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero') + } + + const options = { path: socketPath, ...opts } + const sessionCache = new SessionCache(maxCachedSessions == null ? 100 : maxCachedSessions) + timeout = timeout == null ? 10e3 : timeout + allowH2 = allowH2 != null ? allowH2 : false + return function connect ({ hostname, host, protocol, port, servername, localAddress, httpSocket }, callback) { + let socket + if (protocol === 'https:') { + if (!tls) { + tls = require('tls') + } + servername = servername || options.servername || util.getServerName(host) || null + + const sessionKey = servername || hostname + const session = sessionCache.get(sessionKey) || null + + assert(sessionKey) + + socket = tls.connect({ + highWaterMark: 16384, // TLS in node can't have bigger HWM anyway... + ...options, + servername, + session, + localAddress, + // TODO(HTTP/2): Add support for h2c + ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'], + socket: httpSocket, // upgrade socket connection + port: port || 443, + host: hostname + }) + + socket + .on('session', function (session) { + // TODO (fix): Can a session become invalid once established? Don't think so? + sessionCache.set(sessionKey, session) + }) + } else { + assert(!httpSocket, 'httpSocket can only be sent on TLS update') + socket = net.connect({ + highWaterMark: 64 * 1024, // Same as nodejs fs streams. + ...options, + localAddress, + port: port || 80, + host: hostname + }) + } + + // Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket + if (options.keepAlive == null || options.keepAlive) { + const keepAliveInitialDelay = options.keepAliveInitialDelay === undefined ? 60e3 : options.keepAliveInitialDelay + socket.setKeepAlive(true, keepAliveInitialDelay) + } + + const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout) + + socket + .setNoDelay(true) + .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () { + cancelTimeout() + + if (callback) { + const cb = callback + callback = null + cb(null, this) + } + }) + .on('error', function (err) { + cancelTimeout() + + if (callback) { + const cb = callback + callback = null + cb(err) + } + }) + + return socket + } +} + +function setupTimeout (onConnectTimeout, timeout) { + if (!timeout) { + return () => {} + } + + let s1 = null + let s2 = null + const timeoutId = setTimeout(() => { + // setImmediate is added to make sure that we priotorise socket error events over timeouts + s1 = setImmediate(() => { + if (process.platform === 'win32') { + // Windows needs an extra setImmediate probably due to implementation differences in the socket logic + s2 = setImmediate(() => onConnectTimeout()) + } else { + onConnectTimeout() + } + }) + }, timeout) + return () => { + clearTimeout(timeoutId) + clearImmediate(s1) + clearImmediate(s2) + } +} + +function onConnectTimeout (socket) { + util.destroy(socket, new ConnectTimeoutError()) +} + +module.exports = buildConnector diff --git a/lib/core/constants.js b/lib/core/constants.js new file mode 100644 index 0000000..6ec770d --- /dev/null +++ b/lib/core/constants.js @@ -0,0 +1,118 @@ +'use strict' + +/** @type {Record} */ +const headerNameLowerCasedRecord = {} + +// https://developer.mozilla.org/docs/Web/HTTP/Headers +const wellknownHeaderNames = [ + 'Accept', + 'Accept-Encoding', + 'Accept-Language', + 'Accept-Ranges', + 'Access-Control-Allow-Credentials', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Origin', + 'Access-Control-Expose-Headers', + 'Access-Control-Max-Age', + 'Access-Control-Request-Headers', + 'Access-Control-Request-Method', + 'Age', + 'Allow', + 'Alt-Svc', + 'Alt-Used', + 'Authorization', + 'Cache-Control', + 'Clear-Site-Data', + 'Connection', + 'Content-Disposition', + 'Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-Location', + 'Content-Range', + 'Content-Security-Policy', + 'Content-Security-Policy-Report-Only', + 'Content-Type', + 'Cookie', + 'Cross-Origin-Embedder-Policy', + 'Cross-Origin-Opener-Policy', + 'Cross-Origin-Resource-Policy', + 'Date', + 'Device-Memory', + 'Downlink', + 'ECT', + 'ETag', + 'Expect', + 'Expect-CT', + 'Expires', + 'Forwarded', + 'From', + 'Host', + 'If-Match', + 'If-Modified-Since', + 'If-None-Match', + 'If-Range', + 'If-Unmodified-Since', + 'Keep-Alive', + 'Last-Modified', + 'Link', + 'Location', + 'Max-Forwards', + 'Origin', + 'Permissions-Policy', + 'Pragma', + 'Proxy-Authenticate', + 'Proxy-Authorization', + 'RTT', + 'Range', + 'Referer', + 'Referrer-Policy', + 'Refresh', + 'Retry-After', + 'Sec-WebSocket-Accept', + 'Sec-WebSocket-Extensions', + 'Sec-WebSocket-Key', + 'Sec-WebSocket-Protocol', + 'Sec-WebSocket-Version', + 'Server', + 'Server-Timing', + 'Service-Worker-Allowed', + 'Service-Worker-Navigation-Preload', + 'Set-Cookie', + 'SourceMap', + 'Strict-Transport-Security', + 'Supports-Loading-Mode', + 'TE', + 'Timing-Allow-Origin', + 'Trailer', + 'Transfer-Encoding', + 'Upgrade', + 'Upgrade-Insecure-Requests', + 'User-Agent', + 'Vary', + 'Via', + 'WWW-Authenticate', + 'X-Content-Type-Options', + 'X-DNS-Prefetch-Control', + 'X-Frame-Options', + 'X-Permitted-Cross-Domain-Policies', + 'X-Powered-By', + 'X-Requested-With', + 'X-XSS-Protection' +] + +for (let i = 0; i < wellknownHeaderNames.length; ++i) { + const key = wellknownHeaderNames[i] + const lowerCasedKey = key.toLowerCase() + headerNameLowerCasedRecord[key] = headerNameLowerCasedRecord[lowerCasedKey] = + lowerCasedKey +} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(headerNameLowerCasedRecord, null) + +module.exports = { + wellknownHeaderNames, + headerNameLowerCasedRecord +} diff --git a/lib/core/errors.js b/lib/core/errors.js new file mode 100644 index 0000000..7af704b --- /dev/null +++ b/lib/core/errors.js @@ -0,0 +1,230 @@ +'use strict' + +class UndiciError extends Error { + constructor (message) { + super(message) + this.name = 'UndiciError' + this.code = 'UND_ERR' + } +} + +class ConnectTimeoutError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, ConnectTimeoutError) + this.name = 'ConnectTimeoutError' + this.message = message || 'Connect Timeout Error' + this.code = 'UND_ERR_CONNECT_TIMEOUT' + } +} + +class HeadersTimeoutError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, HeadersTimeoutError) + this.name = 'HeadersTimeoutError' + this.message = message || 'Headers Timeout Error' + this.code = 'UND_ERR_HEADERS_TIMEOUT' + } +} + +class HeadersOverflowError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, HeadersOverflowError) + this.name = 'HeadersOverflowError' + this.message = message || 'Headers Overflow Error' + this.code = 'UND_ERR_HEADERS_OVERFLOW' + } +} + +class BodyTimeoutError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, BodyTimeoutError) + this.name = 'BodyTimeoutError' + this.message = message || 'Body Timeout Error' + this.code = 'UND_ERR_BODY_TIMEOUT' + } +} + +class ResponseStatusCodeError extends UndiciError { + constructor (message, statusCode, headers, body) { + super(message) + Error.captureStackTrace(this, ResponseStatusCodeError) + this.name = 'ResponseStatusCodeError' + this.message = message || 'Response Status Code Error' + this.code = 'UND_ERR_RESPONSE_STATUS_CODE' + this.body = body + this.status = statusCode + this.statusCode = statusCode + this.headers = headers + } +} + +class InvalidArgumentError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, InvalidArgumentError) + this.name = 'InvalidArgumentError' + this.message = message || 'Invalid Argument Error' + this.code = 'UND_ERR_INVALID_ARG' + } +} + +class InvalidReturnValueError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, InvalidReturnValueError) + this.name = 'InvalidReturnValueError' + this.message = message || 'Invalid Return Value Error' + this.code = 'UND_ERR_INVALID_RETURN_VALUE' + } +} + +class RequestAbortedError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, RequestAbortedError) + this.name = 'AbortError' + this.message = message || 'Request aborted' + this.code = 'UND_ERR_ABORTED' + } +} + +class InformationalError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, InformationalError) + this.name = 'InformationalError' + this.message = message || 'Request information' + this.code = 'UND_ERR_INFO' + } +} + +class RequestContentLengthMismatchError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, RequestContentLengthMismatchError) + this.name = 'RequestContentLengthMismatchError' + this.message = message || 'Request body length does not match content-length header' + this.code = 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH' + } +} + +class ResponseContentLengthMismatchError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, ResponseContentLengthMismatchError) + this.name = 'ResponseContentLengthMismatchError' + this.message = message || 'Response body length does not match content-length header' + this.code = 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH' + } +} + +class ClientDestroyedError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, ClientDestroyedError) + this.name = 'ClientDestroyedError' + this.message = message || 'The client is destroyed' + this.code = 'UND_ERR_DESTROYED' + } +} + +class ClientClosedError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, ClientClosedError) + this.name = 'ClientClosedError' + this.message = message || 'The client is closed' + this.code = 'UND_ERR_CLOSED' + } +} + +class SocketError extends UndiciError { + constructor (message, socket) { + super(message) + Error.captureStackTrace(this, SocketError) + this.name = 'SocketError' + this.message = message || 'Socket error' + this.code = 'UND_ERR_SOCKET' + this.socket = socket + } +} + +class NotSupportedError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, NotSupportedError) + this.name = 'NotSupportedError' + this.message = message || 'Not supported error' + this.code = 'UND_ERR_NOT_SUPPORTED' + } +} + +class BalancedPoolMissingUpstreamError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, NotSupportedError) + this.name = 'MissingUpstreamError' + this.message = message || 'No upstream has been added to the BalancedPool' + this.code = 'UND_ERR_BPL_MISSING_UPSTREAM' + } +} + +class HTTPParserError extends Error { + constructor (message, code, data) { + super(message) + Error.captureStackTrace(this, HTTPParserError) + this.name = 'HTTPParserError' + this.code = code ? `HPE_${code}` : undefined + this.data = data ? data.toString() : undefined + } +} + +class ResponseExceededMaxSizeError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, ResponseExceededMaxSizeError) + this.name = 'ResponseExceededMaxSizeError' + this.message = message || 'Response content exceeded max size' + this.code = 'UND_ERR_RES_EXCEEDED_MAX_SIZE' + } +} + +class RequestRetryError extends UndiciError { + constructor (message, code, { headers, data }) { + super(message) + Error.captureStackTrace(this, RequestRetryError) + this.name = 'RequestRetryError' + this.message = message || 'Request retry error' + this.code = 'UND_ERR_REQ_RETRY' + this.statusCode = code + this.data = data + this.headers = headers + } +} + +module.exports = { + HTTPParserError, + UndiciError, + HeadersTimeoutError, + HeadersOverflowError, + BodyTimeoutError, + RequestContentLengthMismatchError, + ConnectTimeoutError, + ResponseStatusCodeError, + InvalidArgumentError, + InvalidReturnValueError, + RequestAbortedError, + ClientDestroyedError, + ClientClosedError, + InformationalError, + SocketError, + NotSupportedError, + ResponseContentLengthMismatchError, + BalancedPoolMissingUpstreamError, + ResponseExceededMaxSizeError, + RequestRetryError +} diff --git a/lib/core/request.js b/lib/core/request.js new file mode 100644 index 0000000..3697e6a --- /dev/null +++ b/lib/core/request.js @@ -0,0 +1,499 @@ +'use strict' + +const { + InvalidArgumentError, + NotSupportedError +} = require('./errors') +const assert = require('assert') +const { kHTTP2BuildRequest, kHTTP2CopyHeaders, kHTTP1BuildRequest } = require('./symbols') +const util = require('./util') + +// tokenRegExp and headerCharRegex have been lifted from +// https://github.com/nodejs/node/blob/main/lib/_http_common.js + +/** + * Verifies that the given val is a valid HTTP token + * per the rules defined in RFC 7230 + * See https://tools.ietf.org/html/rfc7230#section-3.2.6 + */ +const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/ + +/** + * Matches if val contains an invalid field-vchar + * field-value = *( field-content / obs-fold ) + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + */ +const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/ + +// Verifies that a given path is valid does not contain control chars \x00 to \x20 +const invalidPathRegex = /[^\u0021-\u00ff]/ + +const kHandler = Symbol('handler') + +const channels = {} + +let extractBody + +try { + const diagnosticsChannel = require('diagnostics_channel') + channels.create = diagnosticsChannel.channel('undici:request:create') + channels.bodySent = diagnosticsChannel.channel('undici:request:bodySent') + channels.headers = diagnosticsChannel.channel('undici:request:headers') + channels.trailers = diagnosticsChannel.channel('undici:request:trailers') + channels.error = diagnosticsChannel.channel('undici:request:error') +} catch { + channels.create = { hasSubscribers: false } + channels.bodySent = { hasSubscribers: false } + channels.headers = { hasSubscribers: false } + channels.trailers = { hasSubscribers: false } + channels.error = { hasSubscribers: false } +} + +class Request { + constructor (origin, { + path, + method, + body, + headers, + query, + idempotent, + blocking, + upgrade, + headersTimeout, + bodyTimeout, + reset, + throwOnError, + expectContinue + }, handler) { + if (typeof path !== 'string') { + throw new InvalidArgumentError('path must be a string') + } else if ( + path[0] !== '/' && + !(path.startsWith('http://') || path.startsWith('https://')) && + method !== 'CONNECT' + ) { + throw new InvalidArgumentError('path must be an absolute URL or start with a slash') + } else if (invalidPathRegex.exec(path) !== null) { + throw new InvalidArgumentError('invalid request path') + } + + if (typeof method !== 'string') { + throw new InvalidArgumentError('method must be a string') + } else if (tokenRegExp.exec(method) === null) { + throw new InvalidArgumentError('invalid request method') + } + + if (upgrade && typeof upgrade !== 'string') { + throw new InvalidArgumentError('upgrade must be a string') + } + + if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) { + throw new InvalidArgumentError('invalid headersTimeout') + } + + if (bodyTimeout != null && (!Number.isFinite(bodyTimeout) || bodyTimeout < 0)) { + throw new InvalidArgumentError('invalid bodyTimeout') + } + + if (reset != null && typeof reset !== 'boolean') { + throw new InvalidArgumentError('invalid reset') + } + + if (expectContinue != null && typeof expectContinue !== 'boolean') { + throw new InvalidArgumentError('invalid expectContinue') + } + + this.headersTimeout = headersTimeout + + this.bodyTimeout = bodyTimeout + + this.throwOnError = throwOnError === true + + this.method = method + + this.abort = null + + if (body == null) { + this.body = null + } else if (util.isStream(body)) { + this.body = body + + const rState = this.body._readableState + if (!rState || !rState.autoDestroy) { + this.endHandler = function autoDestroy () { + util.destroy(this) + } + this.body.on('end', this.endHandler) + } + + this.errorHandler = err => { + if (this.abort) { + this.abort(err) + } else { + this.error = err + } + } + this.body.on('error', this.errorHandler) + } else if (util.isBuffer(body)) { + this.body = body.byteLength ? body : null + } else if (ArrayBuffer.isView(body)) { + this.body = body.buffer.byteLength ? Buffer.from(body.buffer, body.byteOffset, body.byteLength) : null + } else if (body instanceof ArrayBuffer) { + this.body = body.byteLength ? Buffer.from(body) : null + } else if (typeof body === 'string') { + this.body = body.length ? Buffer.from(body) : null + } else if (util.isFormDataLike(body) || util.isIterable(body) || util.isBlobLike(body)) { + this.body = body + } else { + throw new InvalidArgumentError('body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') + } + + this.completed = false + + this.aborted = false + + this.upgrade = upgrade || null + + this.path = query ? util.buildURL(path, query) : path + + this.origin = origin + + this.idempotent = idempotent == null + ? method === 'HEAD' || method === 'GET' + : idempotent + + this.blocking = blocking == null ? false : blocking + + this.reset = reset == null ? null : reset + + this.host = null + + this.contentLength = null + + this.contentType = null + + this.headers = '' + + // Only for H2 + this.expectContinue = expectContinue != null ? expectContinue : false + + if (Array.isArray(headers)) { + if (headers.length % 2 !== 0) { + throw new InvalidArgumentError('headers array must be even') + } + for (let i = 0; i < headers.length; i += 2) { + processHeader(this, headers[i], headers[i + 1]) + } + } else if (headers && typeof headers === 'object') { + const keys = Object.keys(headers) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + processHeader(this, key, headers[key]) + } + } else if (headers != null) { + throw new InvalidArgumentError('headers must be an object or an array') + } + + if (util.isFormDataLike(this.body)) { + if (util.nodeMajor < 16 || (util.nodeMajor === 16 && util.nodeMinor < 8)) { + throw new InvalidArgumentError('Form-Data bodies are only supported in node v16.8 and newer.') + } + + if (!extractBody) { + extractBody = require('../fetch/body.js').extractBody + } + + const [bodyStream, contentType] = extractBody(body) + if (this.contentType == null) { + this.contentType = contentType + this.headers += `content-type: ${contentType}\r\n` + } + this.body = bodyStream.stream + this.contentLength = bodyStream.length + } else if (util.isBlobLike(body) && this.contentType == null && body.type) { + this.contentType = body.type + this.headers += `content-type: ${body.type}\r\n` + } + + util.validateHandler(handler, method, upgrade) + + this.servername = util.getServerName(this.host) + + this[kHandler] = handler + + if (channels.create.hasSubscribers) { + channels.create.publish({ request: this }) + } + } + + onBodySent (chunk) { + if (this[kHandler].onBodySent) { + try { + return this[kHandler].onBodySent(chunk) + } catch (err) { + this.abort(err) + } + } + } + + onRequestSent () { + if (channels.bodySent.hasSubscribers) { + channels.bodySent.publish({ request: this }) + } + + if (this[kHandler].onRequestSent) { + try { + return this[kHandler].onRequestSent() + } catch (err) { + this.abort(err) + } + } + } + + onConnect (abort) { + assert(!this.aborted) + assert(!this.completed) + + if (this.error) { + abort(this.error) + } else { + this.abort = abort + return this[kHandler].onConnect(abort) + } + } + + onHeaders (statusCode, headers, resume, statusText) { + assert(!this.aborted) + assert(!this.completed) + + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) + } + + try { + return this[kHandler].onHeaders(statusCode, headers, resume, statusText) + } catch (err) { + this.abort(err) + } + } + + onData (chunk) { + assert(!this.aborted) + assert(!this.completed) + + try { + return this[kHandler].onData(chunk) + } catch (err) { + this.abort(err) + return false + } + } + + onUpgrade (statusCode, headers, socket) { + assert(!this.aborted) + assert(!this.completed) + + return this[kHandler].onUpgrade(statusCode, headers, socket) + } + + onComplete (trailers) { + this.onFinally() + + assert(!this.aborted) + + this.completed = true + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers }) + } + + try { + return this[kHandler].onComplete(trailers) + } catch (err) { + // TODO (fix): This might be a bad idea? + this.onError(err) + } + } + + onError (error) { + this.onFinally() + + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }) + } + + if (this.aborted) { + return + } + this.aborted = true + + return this[kHandler].onError(error) + } + + onFinally () { + if (this.errorHandler) { + this.body.off('error', this.errorHandler) + this.errorHandler = null + } + + if (this.endHandler) { + this.body.off('end', this.endHandler) + this.endHandler = null + } + } + + // TODO: adjust to support H2 + addHeader (key, value) { + processHeader(this, key, value) + return this + } + + static [kHTTP1BuildRequest] (origin, opts, handler) { + // TODO: Migrate header parsing here, to make Requests + // HTTP agnostic + return new Request(origin, opts, handler) + } + + static [kHTTP2BuildRequest] (origin, opts, handler) { + const headers = opts.headers + opts = { ...opts, headers: null } + + const request = new Request(origin, opts, handler) + + request.headers = {} + + if (Array.isArray(headers)) { + if (headers.length % 2 !== 0) { + throw new InvalidArgumentError('headers array must be even') + } + for (let i = 0; i < headers.length; i += 2) { + processHeader(request, headers[i], headers[i + 1], true) + } + } else if (headers && typeof headers === 'object') { + const keys = Object.keys(headers) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + processHeader(request, key, headers[key], true) + } + } else if (headers != null) { + throw new InvalidArgumentError('headers must be an object or an array') + } + + return request + } + + static [kHTTP2CopyHeaders] (raw) { + const rawHeaders = raw.split('\r\n') + const headers = {} + + for (const header of rawHeaders) { + const [key, value] = header.split(': ') + + if (value == null || value.length === 0) continue + + if (headers[key]) headers[key] += `,${value}` + else headers[key] = value + } + + return headers + } +} + +function processHeaderValue (key, val, skipAppend) { + if (val && typeof val === 'object') { + throw new InvalidArgumentError(`invalid ${key} header`) + } + + val = val != null ? `${val}` : '' + + if (headerCharRegex.exec(val) !== null) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + + return skipAppend ? val : `${key}: ${val}\r\n` +} + +function processHeader (request, key, val, skipAppend = false) { + if (val && (typeof val === 'object' && !Array.isArray(val))) { + throw new InvalidArgumentError(`invalid ${key} header`) + } else if (val === undefined) { + return + } + + if ( + request.host === null && + key.length === 4 && + key.toLowerCase() === 'host' + ) { + if (headerCharRegex.exec(val) !== null) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + // Consumed by Client + request.host = val + } else if ( + request.contentLength === null && + key.length === 14 && + key.toLowerCase() === 'content-length' + ) { + request.contentLength = parseInt(val, 10) + if (!Number.isFinite(request.contentLength)) { + throw new InvalidArgumentError('invalid content-length header') + } + } else if ( + request.contentType === null && + key.length === 12 && + key.toLowerCase() === 'content-type' + ) { + request.contentType = val + if (skipAppend) request.headers[key] = processHeaderValue(key, val, skipAppend) + else request.headers += processHeaderValue(key, val) + } else if ( + key.length === 17 && + key.toLowerCase() === 'transfer-encoding' + ) { + throw new InvalidArgumentError('invalid transfer-encoding header') + } else if ( + key.length === 10 && + key.toLowerCase() === 'connection' + ) { + const value = typeof val === 'string' ? val.toLowerCase() : null + if (value !== 'close' && value !== 'keep-alive') { + throw new InvalidArgumentError('invalid connection header') + } else if (value === 'close') { + request.reset = true + } + } else if ( + key.length === 10 && + key.toLowerCase() === 'keep-alive' + ) { + throw new InvalidArgumentError('invalid keep-alive header') + } else if ( + key.length === 7 && + key.toLowerCase() === 'upgrade' + ) { + throw new InvalidArgumentError('invalid upgrade header') + } else if ( + key.length === 6 && + key.toLowerCase() === 'expect' + ) { + throw new NotSupportedError('expect header not supported') + } else if (tokenRegExp.exec(key) === null) { + throw new InvalidArgumentError('invalid header key') + } else { + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) { + if (skipAppend) { + if (request.headers[key]) request.headers[key] += `,${processHeaderValue(key, val[i], skipAppend)}` + else request.headers[key] = processHeaderValue(key, val[i], skipAppend) + } else { + request.headers += processHeaderValue(key, val[i]) + } + } + } else { + if (skipAppend) request.headers[key] = processHeaderValue(key, val, skipAppend) + else request.headers += processHeaderValue(key, val) + } + } +} + +module.exports = Request diff --git a/lib/core/symbols.js b/lib/core/symbols.js new file mode 100644 index 0000000..68d8566 --- /dev/null +++ b/lib/core/symbols.js @@ -0,0 +1,63 @@ +module.exports = { + kClose: Symbol('close'), + kDestroy: Symbol('destroy'), + kDispatch: Symbol('dispatch'), + kUrl: Symbol('url'), + kWriting: Symbol('writing'), + kResuming: Symbol('resuming'), + kQueue: Symbol('queue'), + kConnect: Symbol('connect'), + kConnecting: Symbol('connecting'), + kHeadersList: Symbol('headers list'), + kKeepAliveDefaultTimeout: Symbol('default keep alive timeout'), + kKeepAliveMaxTimeout: Symbol('max keep alive timeout'), + kKeepAliveTimeoutThreshold: Symbol('keep alive timeout threshold'), + kKeepAliveTimeoutValue: Symbol('keep alive timeout'), + kKeepAlive: Symbol('keep alive'), + kHeadersTimeout: Symbol('headers timeout'), + kBodyTimeout: Symbol('body timeout'), + kServerName: Symbol('server name'), + kLocalAddress: Symbol('local address'), + kHost: Symbol('host'), + kNoRef: Symbol('no ref'), + kBodyUsed: Symbol('used'), + kRunning: Symbol('running'), + kBlocking: Symbol('blocking'), + kPending: Symbol('pending'), + kSize: Symbol('size'), + kBusy: Symbol('busy'), + kQueued: Symbol('queued'), + kFree: Symbol('free'), + kConnected: Symbol('connected'), + kClosed: Symbol('closed'), + kNeedDrain: Symbol('need drain'), + kReset: Symbol('reset'), + kDestroyed: Symbol.for('nodejs.stream.destroyed'), + kMaxHeadersSize: Symbol('max headers size'), + kRunningIdx: Symbol('running index'), + kPendingIdx: Symbol('pending index'), + kError: Symbol('error'), + kClients: Symbol('clients'), + kClient: Symbol('client'), + kParser: Symbol('parser'), + kOnDestroyed: Symbol('destroy callbacks'), + kPipelining: Symbol('pipelining'), + kSocket: Symbol('socket'), + kHostHeader: Symbol('host header'), + kConnector: Symbol('connector'), + kStrictContentLength: Symbol('strict content length'), + kMaxRedirections: Symbol('maxRedirections'), + kMaxRequests: Symbol('maxRequestsPerClient'), + kProxy: Symbol('proxy agent options'), + kCounter: Symbol('socket request counter'), + kInterceptors: Symbol('dispatch interceptors'), + kMaxResponseSize: Symbol('max response size'), + kHTTP2Session: Symbol('http2Session'), + kHTTP2SessionState: Symbol('http2Session state'), + kHTTP2BuildRequest: Symbol('http2 build request'), + kHTTP1BuildRequest: Symbol('http1 build request'), + kHTTP2CopyHeaders: Symbol('http2 copy headers'), + kHTTPConnVersion: Symbol('http connection version'), + kRetryHandlerDefaultRetry: Symbol('retry agent default retry'), + kConstruct: Symbol('constructable') +} diff --git a/lib/core/util.js b/lib/core/util.js new file mode 100644 index 0000000..5741650 --- /dev/null +++ b/lib/core/util.js @@ -0,0 +1,522 @@ +'use strict' + +const assert = require('assert') +const { kDestroyed, kBodyUsed } = require('./symbols') +const { IncomingMessage } = require('http') +const stream = require('stream') +const net = require('net') +const { InvalidArgumentError } = require('./errors') +const { Blob } = require('buffer') +const nodeUtil = require('util') +const { stringify } = require('querystring') +const { headerNameLowerCasedRecord } = require('./constants') + +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v)) + +function nop () {} + +function isStream (obj) { + return obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function' +} + +// based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License) +function isBlobLike (object) { + return (Blob && object instanceof Blob) || ( + object && + typeof object === 'object' && + (typeof object.stream === 'function' || + typeof object.arrayBuffer === 'function') && + /^(Blob|File)$/.test(object[Symbol.toStringTag]) + ) +} + +function buildURL (url, queryParams) { + if (url.includes('?') || url.includes('#')) { + throw new Error('Query params cannot be passed when url already contains "?" or "#".') + } + + const stringified = stringify(queryParams) + + if (stringified) { + url += '?' + stringified + } + + return url +} + +function parseURL (url) { + if (typeof url === 'string') { + url = new URL(url) + + if (!/^https?:/.test(url.origin || url.protocol)) { + throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + return url + } + + if (!url || typeof url !== 'object') { + throw new InvalidArgumentError('Invalid URL: The URL argument must be a non-null object.') + } + + if (!/^https?:/.test(url.origin || url.protocol)) { + throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + if (!(url instanceof URL)) { + if (url.port != null && url.port !== '' && !Number.isFinite(parseInt(url.port))) { + throw new InvalidArgumentError('Invalid URL: port must be a valid integer or a string representation of an integer.') + } + + if (url.path != null && typeof url.path !== 'string') { + throw new InvalidArgumentError('Invalid URL path: the path must be a string or null/undefined.') + } + + if (url.pathname != null && typeof url.pathname !== 'string') { + throw new InvalidArgumentError('Invalid URL pathname: the pathname must be a string or null/undefined.') + } + + if (url.hostname != null && typeof url.hostname !== 'string') { + throw new InvalidArgumentError('Invalid URL hostname: the hostname must be a string or null/undefined.') + } + + if (url.origin != null && typeof url.origin !== 'string') { + throw new InvalidArgumentError('Invalid URL origin: the origin must be a string or null/undefined.') + } + + const port = url.port != null + ? url.port + : (url.protocol === 'https:' ? 443 : 80) + let origin = url.origin != null + ? url.origin + : `${url.protocol}//${url.hostname}:${port}` + let path = url.path != null + ? url.path + : `${url.pathname || ''}${url.search || ''}` + + if (origin.endsWith('/')) { + origin = origin.substring(0, origin.length - 1) + } + + if (path && !path.startsWith('/')) { + path = `/${path}` + } + // new URL(path, origin) is unsafe when `path` contains an absolute URL + // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL: + // If first parameter is a relative URL, second param is required, and will be used as the base URL. + // If first parameter is an absolute URL, a given second param will be ignored. + url = new URL(origin + path) + } + + return url +} + +function parseOrigin (url) { + url = parseURL(url) + + if (url.pathname !== '/' || url.search || url.hash) { + throw new InvalidArgumentError('invalid url') + } + + return url +} + +function getHostname (host) { + if (host[0] === '[') { + const idx = host.indexOf(']') + + assert(idx !== -1) + return host.substring(1, idx) + } + + const idx = host.indexOf(':') + if (idx === -1) return host + + return host.substring(0, idx) +} + +// IP addresses are not valid server names per RFC6066 +// > Currently, the only server names supported are DNS hostnames +function getServerName (host) { + if (!host) { + return null + } + + assert.strictEqual(typeof host, 'string') + + const servername = getHostname(host) + if (net.isIP(servername)) { + return '' + } + + return servername +} + +function deepClone (obj) { + return JSON.parse(JSON.stringify(obj)) +} + +function isAsyncIterable (obj) { + return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function') +} + +function isIterable (obj) { + return !!(obj != null && (typeof obj[Symbol.iterator] === 'function' || typeof obj[Symbol.asyncIterator] === 'function')) +} + +function bodyLength (body) { + if (body == null) { + return 0 + } else if (isStream(body)) { + const state = body._readableState + return state && state.objectMode === false && state.ended === true && Number.isFinite(state.length) + ? state.length + : null + } else if (isBlobLike(body)) { + return body.size != null ? body.size : null + } else if (isBuffer(body)) { + return body.byteLength + } + + return null +} + +function isDestroyed (stream) { + return !stream || !!(stream.destroyed || stream[kDestroyed]) +} + +function isReadableAborted (stream) { + const state = stream && stream._readableState + return isDestroyed(stream) && state && !state.endEmitted +} + +function destroy (stream, err) { + if (stream == null || !isStream(stream) || isDestroyed(stream)) { + return + } + + if (typeof stream.destroy === 'function') { + if (Object.getPrototypeOf(stream).constructor === IncomingMessage) { + // See: https://github.com/nodejs/node/pull/38505/files + stream.socket = null + } + + stream.destroy(err) + } else if (err) { + process.nextTick((stream, err) => { + stream.emit('error', err) + }, stream, err) + } + + if (stream.destroyed !== true) { + stream[kDestroyed] = true + } +} + +const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/ +function parseKeepAliveTimeout (val) { + const m = val.toString().match(KEEPALIVE_TIMEOUT_EXPR) + return m ? parseInt(m[1], 10) * 1000 : null +} + +/** + * Retrieves a header name and returns its lowercase value. + * @param {string | Buffer} value Header name + * @returns {string} + */ +function headerNameToString (value) { + return headerNameLowerCasedRecord[value] || value.toLowerCase() +} + +function parseHeaders (headers, obj = {}) { + // For H2 support + if (!Array.isArray(headers)) return headers + + for (let i = 0; i < headers.length; i += 2) { + const key = headers[i].toString().toLowerCase() + let val = obj[key] + + if (!val) { + if (Array.isArray(headers[i + 1])) { + obj[key] = headers[i + 1].map(x => x.toString('utf8')) + } else { + obj[key] = headers[i + 1].toString('utf8') + } + } else { + if (!Array.isArray(val)) { + val = [val] + obj[key] = val + } + val.push(headers[i + 1].toString('utf8')) + } + } + + // See https://github.com/nodejs/node/pull/46528 + if ('content-length' in obj && 'content-disposition' in obj) { + obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1') + } + + return obj +} + +function parseRawHeaders (headers) { + const ret = [] + let hasContentLength = false + let contentDispositionIdx = -1 + + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n + 0].toString() + const val = headers[n + 1].toString('utf8') + + if (key.length === 14 && (key === 'content-length' || key.toLowerCase() === 'content-length')) { + ret.push(key, val) + hasContentLength = true + } else if (key.length === 19 && (key === 'content-disposition' || key.toLowerCase() === 'content-disposition')) { + contentDispositionIdx = ret.push(key, val) - 1 + } else { + ret.push(key, val) + } + } + + // See https://github.com/nodejs/node/pull/46528 + if (hasContentLength && contentDispositionIdx !== -1) { + ret[contentDispositionIdx] = Buffer.from(ret[contentDispositionIdx]).toString('latin1') + } + + return ret +} + +function isBuffer (buffer) { + // See, https://github.com/mcollina/undici/pull/319 + return buffer instanceof Uint8Array || Buffer.isBuffer(buffer) +} + +function validateHandler (handler, method, upgrade) { + if (!handler || typeof handler !== 'object') { + throw new InvalidArgumentError('handler must be an object') + } + + if (typeof handler.onConnect !== 'function') { + throw new InvalidArgumentError('invalid onConnect method') + } + + if (typeof handler.onError !== 'function') { + throw new InvalidArgumentError('invalid onError method') + } + + if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) { + throw new InvalidArgumentError('invalid onBodySent method') + } + + if (upgrade || method === 'CONNECT') { + if (typeof handler.onUpgrade !== 'function') { + throw new InvalidArgumentError('invalid onUpgrade method') + } + } else { + if (typeof handler.onHeaders !== 'function') { + throw new InvalidArgumentError('invalid onHeaders method') + } + + if (typeof handler.onData !== 'function') { + throw new InvalidArgumentError('invalid onData method') + } + + if (typeof handler.onComplete !== 'function') { + throw new InvalidArgumentError('invalid onComplete method') + } + } +} + +// A body is disturbed if it has been read from and it cannot +// be re-used without losing state or data. +function isDisturbed (body) { + return !!(body && ( + stream.isDisturbed + ? stream.isDisturbed(body) || body[kBodyUsed] // TODO (fix): Why is body[kBodyUsed] needed? + : body[kBodyUsed] || + body.readableDidRead || + (body._readableState && body._readableState.dataEmitted) || + isReadableAborted(body) + )) +} + +function isErrored (body) { + return !!(body && ( + stream.isErrored + ? stream.isErrored(body) + : /state: 'errored'/.test(nodeUtil.inspect(body) + ))) +} + +function isReadable (body) { + return !!(body && ( + stream.isReadable + ? stream.isReadable(body) + : /state: 'readable'/.test(nodeUtil.inspect(body) + ))) +} + +function getSocketInfo (socket) { + return { + localAddress: socket.localAddress, + localPort: socket.localPort, + remoteAddress: socket.remoteAddress, + remotePort: socket.remotePort, + remoteFamily: socket.remoteFamily, + timeout: socket.timeout, + bytesWritten: socket.bytesWritten, + bytesRead: socket.bytesRead + } +} + +async function * convertIterableToBuffer (iterable) { + for await (const chunk of iterable) { + yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + } +} + +let ReadableStream +function ReadableStreamFrom (iterable) { + if (!ReadableStream) { + ReadableStream = require('stream/web').ReadableStream + } + + if (ReadableStream.from) { + return ReadableStream.from(convertIterableToBuffer(iterable)) + } + + let iterator + return new ReadableStream( + { + async start () { + iterator = iterable[Symbol.asyncIterator]() + }, + async pull (controller) { + const { done, value } = await iterator.next() + if (done) { + queueMicrotask(() => { + controller.close() + }) + } else { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value) + controller.enqueue(new Uint8Array(buf)) + } + return controller.desiredSize > 0 + }, + async cancel (reason) { + await iterator.return() + } + }, + 0 + ) +} + +// The chunk should be a FormData instance and contains +// all the required methods. +function isFormDataLike (object) { + return ( + object && + typeof object === 'object' && + typeof object.append === 'function' && + typeof object.delete === 'function' && + typeof object.get === 'function' && + typeof object.getAll === 'function' && + typeof object.has === 'function' && + typeof object.set === 'function' && + object[Symbol.toStringTag] === 'FormData' + ) +} + +function throwIfAborted (signal) { + if (!signal) { return } + if (typeof signal.throwIfAborted === 'function') { + signal.throwIfAborted() + } else { + if (signal.aborted) { + // DOMException not available < v17.0.0 + const err = new Error('The operation was aborted') + err.name = 'AbortError' + throw err + } + } +} + +function addAbortListener (signal, listener) { + if ('addEventListener' in signal) { + signal.addEventListener('abort', listener, { once: true }) + return () => signal.removeEventListener('abort', listener) + } + signal.addListener('abort', listener) + return () => signal.removeListener('abort', listener) +} + +const hasToWellFormed = !!String.prototype.toWellFormed + +/** + * @param {string} val + */ +function toUSVString (val) { + if (hasToWellFormed) { + return `${val}`.toWellFormed() + } else if (nodeUtil.toUSVString) { + return nodeUtil.toUSVString(val) + } + + return `${val}` +} + +// Parsed accordingly to RFC 9110 +// https://www.rfc-editor.org/rfc/rfc9110#field.content-range +function parseRangeHeader (range) { + if (range == null || range === '') return { start: 0, end: null, size: null } + + const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null + return m + ? { + start: parseInt(m[1]), + end: m[2] ? parseInt(m[2]) : null, + size: m[3] ? parseInt(m[3]) : null + } + : null +} + +const kEnumerableProperty = Object.create(null) +kEnumerableProperty.enumerable = true + +module.exports = { + kEnumerableProperty, + nop, + isDisturbed, + isErrored, + isReadable, + toUSVString, + isReadableAborted, + isBlobLike, + parseOrigin, + parseURL, + getServerName, + isStream, + isIterable, + isAsyncIterable, + isDestroyed, + headerNameToString, + parseRawHeaders, + parseHeaders, + parseKeepAliveTimeout, + destroy, + bodyLength, + deepClone, + ReadableStreamFrom, + isBuffer, + validateHandler, + getSocketInfo, + isFormDataLike, + buildURL, + throwIfAborted, + addAbortListener, + parseRangeHeader, + nodeMajor, + nodeMinor, + nodeHasAutoSelectFamily: nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 13), + safeHTTPMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'] +} diff --git a/lib/dispatcher-base.js b/lib/dispatcher-base.js new file mode 100644 index 0000000..5c0220b --- /dev/null +++ b/lib/dispatcher-base.js @@ -0,0 +1,192 @@ +'use strict' + +const Dispatcher = require('./dispatcher') +const { + ClientDestroyedError, + ClientClosedError, + InvalidArgumentError +} = require('./core/errors') +const { kDestroy, kClose, kDispatch, kInterceptors } = require('./core/symbols') + +const kDestroyed = Symbol('destroyed') +const kClosed = Symbol('closed') +const kOnDestroyed = Symbol('onDestroyed') +const kOnClosed = Symbol('onClosed') +const kInterceptedDispatch = Symbol('Intercepted Dispatch') + +class DispatcherBase extends Dispatcher { + constructor () { + super() + + this[kDestroyed] = false + this[kOnDestroyed] = null + this[kClosed] = false + this[kOnClosed] = [] + } + + get destroyed () { + return this[kDestroyed] + } + + get closed () { + return this[kClosed] + } + + get interceptors () { + return this[kInterceptors] + } + + set interceptors (newInterceptors) { + if (newInterceptors) { + for (let i = newInterceptors.length - 1; i >= 0; i--) { + const interceptor = this[kInterceptors][i] + if (typeof interceptor !== 'function') { + throw new InvalidArgumentError('interceptor must be an function') + } + } + } + + this[kInterceptors] = newInterceptors + } + + close (callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + this.close((err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (this[kDestroyed]) { + queueMicrotask(() => callback(new ClientDestroyedError(), null)) + return + } + + if (this[kClosed]) { + if (this[kOnClosed]) { + this[kOnClosed].push(callback) + } else { + queueMicrotask(() => callback(null, null)) + } + return + } + + this[kClosed] = true + this[kOnClosed].push(callback) + + const onClosed = () => { + const callbacks = this[kOnClosed] + this[kOnClosed] = null + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](null, null) + } + } + + // Should not error. + this[kClose]() + .then(() => this.destroy()) + .then(() => { + queueMicrotask(onClosed) + }) + } + + destroy (err, callback) { + if (typeof err === 'function') { + callback = err + err = null + } + + if (callback === undefined) { + return new Promise((resolve, reject) => { + this.destroy(err, (err, data) => { + return err ? /* istanbul ignore next: should never error */ reject(err) : resolve(data) + }) + }) + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (this[kDestroyed]) { + if (this[kOnDestroyed]) { + this[kOnDestroyed].push(callback) + } else { + queueMicrotask(() => callback(null, null)) + } + return + } + + if (!err) { + err = new ClientDestroyedError() + } + + this[kDestroyed] = true + this[kOnDestroyed] = this[kOnDestroyed] || [] + this[kOnDestroyed].push(callback) + + const onDestroyed = () => { + const callbacks = this[kOnDestroyed] + this[kOnDestroyed] = null + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](null, null) + } + } + + // Should not error. + this[kDestroy](err).then(() => { + queueMicrotask(onDestroyed) + }) + } + + [kInterceptedDispatch] (opts, handler) { + if (!this[kInterceptors] || this[kInterceptors].length === 0) { + this[kInterceptedDispatch] = this[kDispatch] + return this[kDispatch](opts, handler) + } + + let dispatch = this[kDispatch].bind(this) + for (let i = this[kInterceptors].length - 1; i >= 0; i--) { + dispatch = this[kInterceptors][i](dispatch) + } + this[kInterceptedDispatch] = dispatch + return dispatch(opts, handler) + } + + dispatch (opts, handler) { + if (!handler || typeof handler !== 'object') { + throw new InvalidArgumentError('handler must be an object') + } + + try { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('opts must be an object.') + } + + if (this[kDestroyed] || this[kOnDestroyed]) { + throw new ClientDestroyedError() + } + + if (this[kClosed]) { + throw new ClientClosedError() + } + + return this[kInterceptedDispatch](opts, handler) + } catch (err) { + if (typeof handler.onError !== 'function') { + throw new InvalidArgumentError('invalid onError method') + } + + handler.onError(err) + + return false + } + } +} + +module.exports = DispatcherBase diff --git a/lib/dispatcher.js b/lib/dispatcher.js new file mode 100644 index 0000000..9b809d8 --- /dev/null +++ b/lib/dispatcher.js @@ -0,0 +1,19 @@ +'use strict' + +const EventEmitter = require('events') + +class Dispatcher extends EventEmitter { + dispatch () { + throw new Error('not implemented') + } + + close () { + throw new Error('not implemented') + } + + destroy () { + throw new Error('not implemented') + } +} + +module.exports = Dispatcher diff --git a/lib/fetch/LICENSE b/lib/fetch/LICENSE new file mode 100644 index 0000000..2943500 --- /dev/null +++ b/lib/fetch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Ethan Arrowood + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/fetch/body.js b/lib/fetch/body.js new file mode 100644 index 0000000..fd8481b --- /dev/null +++ b/lib/fetch/body.js @@ -0,0 +1,605 @@ +'use strict' + +const Busboy = require('@fastify/busboy') +const util = require('../core/util') +const { + ReadableStreamFrom, + isBlobLike, + isReadableStreamLike, + readableStreamClose, + createDeferredPromise, + fullyReadBody +} = require('./util') +const { FormData } = require('./formdata') +const { kState } = require('./symbols') +const { webidl } = require('./webidl') +const { DOMException, structuredClone } = require('./constants') +const { Blob, File: NativeFile } = require('buffer') +const { kBodyUsed } = require('../core/symbols') +const assert = require('assert') +const { isErrored } = require('../core/util') +const { isUint8Array, isArrayBuffer } = require('util/types') +const { File: UndiciFile } = require('./file') +const { parseMIMEType, serializeAMimeType } = require('./dataURL') + +let ReadableStream = globalThis.ReadableStream + +/** @type {globalThis['File']} */ +const File = NativeFile ?? UndiciFile +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +// https://fetch.spec.whatwg.org/#concept-bodyinit-extract +function extractBody (object, keepalive = false) { + if (!ReadableStream) { + ReadableStream = require('stream/web').ReadableStream + } + + // 1. Let stream be null. + let stream = null + + // 2. If object is a ReadableStream object, then set stream to object. + if (object instanceof ReadableStream) { + stream = object + } else if (isBlobLike(object)) { + // 3. Otherwise, if object is a Blob object, set stream to the + // result of running object’s get stream. + stream = object.stream() + } else { + // 4. Otherwise, set stream to a new ReadableStream object, and set + // up stream. + stream = new ReadableStream({ + async pull (controller) { + controller.enqueue( + typeof source === 'string' ? textEncoder.encode(source) : source + ) + queueMicrotask(() => readableStreamClose(controller)) + }, + start () {}, + type: undefined + }) + } + + // 5. Assert: stream is a ReadableStream object. + assert(isReadableStreamLike(stream)) + + // 6. Let action be null. + let action = null + + // 7. Let source be null. + let source = null + + // 8. Let length be null. + let length = null + + // 9. Let type be null. + let type = null + + // 10. Switch on object: + if (typeof object === 'string') { + // Set source to the UTF-8 encoding of object. + // Note: setting source to a Uint8Array here breaks some mocking assumptions. + source = object + + // Set type to `text/plain;charset=UTF-8`. + type = 'text/plain;charset=UTF-8' + } else if (object instanceof URLSearchParams) { + // URLSearchParams + + // spec says to run application/x-www-form-urlencoded on body.list + // this is implemented in Node.js as apart of an URLSearchParams instance toString method + // See: https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/lib/internal/url.js#L490 + // and https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/lib/internal/url.js#L1100 + + // Set source to the result of running the application/x-www-form-urlencoded serializer with object’s list. + source = object.toString() + + // Set type to `application/x-www-form-urlencoded;charset=UTF-8`. + type = 'application/x-www-form-urlencoded;charset=UTF-8' + } else if (isArrayBuffer(object)) { + // BufferSource/ArrayBuffer + + // Set source to a copy of the bytes held by object. + source = new Uint8Array(object.slice()) + } else if (ArrayBuffer.isView(object)) { + // BufferSource/ArrayBufferView + + // Set source to a copy of the bytes held by object. + source = new Uint8Array(object.buffer.slice(object.byteOffset, object.byteOffset + object.byteLength)) + } else if (util.isFormDataLike(object)) { + const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}` + const prefix = `--${boundary}\r\nContent-Disposition: form-data` + + /*! formdata-polyfill. MIT License. Jimmy Wärting */ + const escape = (str) => + str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') + const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n') + + // Set action to this step: run the multipart/form-data + // encoding algorithm, with object’s entry list and UTF-8. + // - This ensures that the body is immutable and can't be changed afterwords + // - That the content-length is calculated in advance. + // - And that all parts are pre-encoded and ready to be sent. + + const blobParts = [] + const rn = new Uint8Array([13, 10]) // '\r\n' + length = 0 + let hasUnknownSizeValue = false + + for (const [name, value] of object) { + if (typeof value === 'string') { + const chunk = textEncoder.encode(prefix + + `; name="${escape(normalizeLinefeeds(name))}"` + + `\r\n\r\n${normalizeLinefeeds(value)}\r\n`) + blobParts.push(chunk) + length += chunk.byteLength + } else { + const chunk = textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"` + + (value.name ? `; filename="${escape(value.name)}"` : '') + '\r\n' + + `Content-Type: ${ + value.type || 'application/octet-stream' + }\r\n\r\n`) + blobParts.push(chunk, value, rn) + if (typeof value.size === 'number') { + length += chunk.byteLength + value.size + rn.byteLength + } else { + hasUnknownSizeValue = true + } + } + } + + const chunk = textEncoder.encode(`--${boundary}--`) + blobParts.push(chunk) + length += chunk.byteLength + if (hasUnknownSizeValue) { + length = null + } + + // Set source to object. + source = object + + action = async function * () { + for (const part of blobParts) { + if (part.stream) { + yield * part.stream() + } else { + yield part + } + } + } + + // Set type to `multipart/form-data; boundary=`, + // followed by the multipart/form-data boundary string generated + // by the multipart/form-data encoding algorithm. + type = 'multipart/form-data; boundary=' + boundary + } else if (isBlobLike(object)) { + // Blob + + // Set source to object. + source = object + + // Set length to object’s size. + length = object.size + + // If object’s type attribute is not the empty byte sequence, set + // type to its value. + if (object.type) { + type = object.type + } + } else if (typeof object[Symbol.asyncIterator] === 'function') { + // If keepalive is true, then throw a TypeError. + if (keepalive) { + throw new TypeError('keepalive') + } + + // If object is disturbed or locked, then throw a TypeError. + if (util.isDisturbed(object) || object.locked) { + throw new TypeError( + 'Response body object should not be disturbed or locked' + ) + } + + stream = + object instanceof ReadableStream ? object : ReadableStreamFrom(object) + } + + // 11. If source is a byte sequence, then set action to a + // step that returns source and length to source’s length. + if (typeof source === 'string' || util.isBuffer(source)) { + length = Buffer.byteLength(source) + } + + // 12. If action is non-null, then run these steps in in parallel: + if (action != null) { + // Run action. + let iterator + stream = new ReadableStream({ + async start () { + iterator = action(object)[Symbol.asyncIterator]() + }, + async pull (controller) { + const { value, done } = await iterator.next() + if (done) { + // When running action is done, close stream. + queueMicrotask(() => { + controller.close() + }) + } else { + // Whenever one or more bytes are available and stream is not errored, + // enqueue a Uint8Array wrapping an ArrayBuffer containing the available + // bytes into stream. + if (!isErrored(stream)) { + controller.enqueue(new Uint8Array(value)) + } + } + return controller.desiredSize > 0 + }, + async cancel (reason) { + await iterator.return() + }, + type: undefined + }) + } + + // 13. Let body be a body whose stream is stream, source is source, + // and length is length. + const body = { stream, source, length } + + // 14. Return (body, type). + return [body, type] +} + +// https://fetch.spec.whatwg.org/#bodyinit-safely-extract +function safelyExtractBody (object, keepalive = false) { + if (!ReadableStream) { + // istanbul ignore next + ReadableStream = require('stream/web').ReadableStream + } + + // To safely extract a body and a `Content-Type` value from + // a byte sequence or BodyInit object object, run these steps: + + // 1. If object is a ReadableStream object, then: + if (object instanceof ReadableStream) { + // Assert: object is neither disturbed nor locked. + // istanbul ignore next + assert(!util.isDisturbed(object), 'The body has already been consumed.') + // istanbul ignore next + assert(!object.locked, 'The stream is locked.') + } + + // 2. Return the results of extracting object. + return extractBody(object, keepalive) +} + +function cloneBody (body) { + // To clone a body body, run these steps: + + // https://fetch.spec.whatwg.org/#concept-body-clone + + // 1. Let « out1, out2 » be the result of teeing body’s stream. + const [out1, out2] = body.stream.tee() + const out2Clone = structuredClone(out2, { transfer: [out2] }) + // This, for whatever reasons, unrefs out2Clone which allows + // the process to exit by itself. + const [, finalClone] = out2Clone.tee() + + // 2. Set body’s stream to out1. + body.stream = out1 + + // 3. Return a body whose stream is out2 and other members are copied from body. + return { + stream: finalClone, + length: body.length, + source: body.source + } +} + +async function * consumeBody (body) { + if (body) { + if (isUint8Array(body)) { + yield body + } else { + const stream = body.stream + + if (util.isDisturbed(stream)) { + throw new TypeError('The body has already been consumed.') + } + + if (stream.locked) { + throw new TypeError('The stream is locked.') + } + + // Compat. + stream[kBodyUsed] = true + + yield * stream + } + } +} + +function throwIfAborted (state) { + if (state.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError') + } +} + +function bodyMixinMethods (instance) { + const methods = { + blob () { + // The blob() method steps are to return the result of + // running consume body with this and the following step + // given a byte sequence bytes: return a Blob whose + // contents are bytes and whose type attribute is this’s + // MIME type. + return specConsumeBody(this, (bytes) => { + let mimeType = bodyMimeType(this) + + if (mimeType === 'failure') { + mimeType = '' + } else if (mimeType) { + mimeType = serializeAMimeType(mimeType) + } + + // Return a Blob whose contents are bytes and type attribute + // is mimeType. + return new Blob([bytes], { type: mimeType }) + }, instance) + }, + + arrayBuffer () { + // The arrayBuffer() method steps are to return the result + // of running consume body with this and the following step + // given a byte sequence bytes: return a new ArrayBuffer + // whose contents are bytes. + return specConsumeBody(this, (bytes) => { + return new Uint8Array(bytes).buffer + }, instance) + }, + + text () { + // The text() method steps are to return the result of running + // consume body with this and UTF-8 decode. + return specConsumeBody(this, utf8DecodeBytes, instance) + }, + + json () { + // The json() method steps are to return the result of running + // consume body with this and parse JSON from bytes. + return specConsumeBody(this, parseJSONFromBytes, instance) + }, + + async formData () { + webidl.brandCheck(this, instance) + + throwIfAborted(this[kState]) + + const contentType = this.headers.get('Content-Type') + + // If mimeType’s essence is "multipart/form-data", then: + if (/multipart\/form-data/.test(contentType)) { + const headers = {} + for (const [key, value] of this.headers) headers[key.toLowerCase()] = value + + const responseFormData = new FormData() + + let busboy + + try { + busboy = new Busboy({ + headers, + preservePath: true + }) + } catch (err) { + throw new DOMException(`${err}`, 'AbortError') + } + + busboy.on('field', (name, value) => { + responseFormData.append(name, value) + }) + busboy.on('file', (name, value, filename, encoding, mimeType) => { + const chunks = [] + + if (encoding === 'base64' || encoding.toLowerCase() === 'base64') { + let base64chunk = '' + + value.on('data', (chunk) => { + base64chunk += chunk.toString().replace(/[\r\n]/gm, '') + + const end = base64chunk.length - base64chunk.length % 4 + chunks.push(Buffer.from(base64chunk.slice(0, end), 'base64')) + + base64chunk = base64chunk.slice(end) + }) + value.on('end', () => { + chunks.push(Buffer.from(base64chunk, 'base64')) + responseFormData.append(name, new File(chunks, filename, { type: mimeType })) + }) + } else { + value.on('data', (chunk) => { + chunks.push(chunk) + }) + value.on('end', () => { + responseFormData.append(name, new File(chunks, filename, { type: mimeType })) + }) + } + }) + + const busboyResolve = new Promise((resolve, reject) => { + busboy.on('finish', resolve) + busboy.on('error', (err) => reject(new TypeError(err))) + }) + + if (this.body !== null) for await (const chunk of consumeBody(this[kState].body)) busboy.write(chunk) + busboy.end() + await busboyResolve + + return responseFormData + } else if (/application\/x-www-form-urlencoded/.test(contentType)) { + // Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then: + + // 1. Let entries be the result of parsing bytes. + let entries + try { + let text = '' + // application/x-www-form-urlencoded parser will keep the BOM. + // https://url.spec.whatwg.org/#concept-urlencoded-parser + // Note that streaming decoder is stateful and cannot be reused + const streamingDecoder = new TextDecoder('utf-8', { ignoreBOM: true }) + + for await (const chunk of consumeBody(this[kState].body)) { + if (!isUint8Array(chunk)) { + throw new TypeError('Expected Uint8Array chunk') + } + text += streamingDecoder.decode(chunk, { stream: true }) + } + text += streamingDecoder.decode() + entries = new URLSearchParams(text) + } catch (err) { + // istanbul ignore next: Unclear when new URLSearchParams can fail on a string. + // 2. If entries is failure, then throw a TypeError. + throw Object.assign(new TypeError(), { cause: err }) + } + + // 3. Return a new FormData object whose entries are entries. + const formData = new FormData() + for (const [name, value] of entries) { + formData.append(name, value) + } + return formData + } else { + // Wait a tick before checking if the request has been aborted. + // Otherwise, a TypeError can be thrown when an AbortError should. + await Promise.resolve() + + throwIfAborted(this[kState]) + + // Otherwise, throw a TypeError. + throw webidl.errors.exception({ + header: `${instance.name}.formData`, + message: 'Could not parse content as FormData.' + }) + } + } + } + + return methods +} + +function mixinBody (prototype) { + Object.assign(prototype.prototype, bodyMixinMethods(prototype)) +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-body-consume-body + * @param {Response|Request} object + * @param {(value: unknown) => unknown} convertBytesToJSValue + * @param {Response|Request} instance + */ +async function specConsumeBody (object, convertBytesToJSValue, instance) { + webidl.brandCheck(object, instance) + + throwIfAborted(object[kState]) + + // 1. If object is unusable, then return a promise rejected + // with a TypeError. + if (bodyUnusable(object[kState].body)) { + throw new TypeError('Body is unusable') + } + + // 2. Let promise be a new promise. + const promise = createDeferredPromise() + + // 3. Let errorSteps given error be to reject promise with error. + const errorSteps = (error) => promise.reject(error) + + // 4. Let successSteps given a byte sequence data be to resolve + // promise with the result of running convertBytesToJSValue + // with data. If that threw an exception, then run errorSteps + // with that exception. + const successSteps = (data) => { + try { + promise.resolve(convertBytesToJSValue(data)) + } catch (e) { + errorSteps(e) + } + } + + // 5. If object’s body is null, then run successSteps with an + // empty byte sequence. + if (object[kState].body == null) { + successSteps(new Uint8Array()) + return promise.promise + } + + // 6. Otherwise, fully read object’s body given successSteps, + // errorSteps, and object’s relevant global object. + await fullyReadBody(object[kState].body, successSteps, errorSteps) + + // 7. Return promise. + return promise.promise +} + +// https://fetch.spec.whatwg.org/#body-unusable +function bodyUnusable (body) { + // An object including the Body interface mixin is + // said to be unusable if its body is non-null and + // its body’s stream is disturbed or locked. + return body != null && (body.stream.locked || util.isDisturbed(body.stream)) +} + +/** + * @see https://encoding.spec.whatwg.org/#utf-8-decode + * @param {Buffer} buffer + */ +function utf8DecodeBytes (buffer) { + if (buffer.length === 0) { + return '' + } + + // 1. Let buffer be the result of peeking three bytes from + // ioQueue, converted to a byte sequence. + + // 2. If buffer is 0xEF 0xBB 0xBF, then read three + // bytes from ioQueue. (Do nothing with those bytes.) + if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + buffer = buffer.subarray(3) + } + + // 3. Process a queue with an instance of UTF-8’s + // decoder, ioQueue, output, and "replacement". + const output = textDecoder.decode(buffer) + + // 4. Return output. + return output +} + +/** + * @see https://infra.spec.whatwg.org/#parse-json-bytes-to-a-javascript-value + * @param {Uint8Array} bytes + */ +function parseJSONFromBytes (bytes) { + return JSON.parse(utf8DecodeBytes(bytes)) +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-body-mime-type + * @param {import('./response').Response|import('./request').Request} object + */ +function bodyMimeType (object) { + const { headersList } = object[kState] + const contentType = headersList.get('content-type') + + if (contentType === null) { + return 'failure' + } + + return parseMIMEType(contentType) +} + +module.exports = { + extractBody, + safelyExtractBody, + cloneBody, + mixinBody +} diff --git a/lib/fetch/constants.js b/lib/fetch/constants.js new file mode 100644 index 0000000..218fcbe --- /dev/null +++ b/lib/fetch/constants.js @@ -0,0 +1,151 @@ +'use strict' + +const { MessageChannel, receiveMessageOnPort } = require('worker_threads') + +const corsSafeListedMethods = ['GET', 'HEAD', 'POST'] +const corsSafeListedMethodsSet = new Set(corsSafeListedMethods) + +const nullBodyStatus = [101, 204, 205, 304] + +const redirectStatus = [301, 302, 303, 307, 308] +const redirectStatusSet = new Set(redirectStatus) + +// https://fetch.spec.whatwg.org/#block-bad-port +const badPorts = [ + '1', '7', '9', '11', '13', '15', '17', '19', '20', '21', '22', '23', '25', '37', '42', '43', '53', '69', '77', '79', + '87', '95', '101', '102', '103', '104', '109', '110', '111', '113', '115', '117', '119', '123', '135', '137', + '139', '143', '161', '179', '389', '427', '465', '512', '513', '514', '515', '526', '530', '531', '532', + '540', '548', '554', '556', '563', '587', '601', '636', '989', '990', '993', '995', '1719', '1720', '1723', + '2049', '3659', '4045', '5060', '5061', '6000', '6566', '6665', '6666', '6667', '6668', '6669', '6697', + '10080' +] + +const badPortsSet = new Set(badPorts) + +// https://w3c.github.io/webappsec-referrer-policy/#referrer-policies +const referrerPolicy = [ + '', + 'no-referrer', + 'no-referrer-when-downgrade', + 'same-origin', + 'origin', + 'strict-origin', + 'origin-when-cross-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url' +] +const referrerPolicySet = new Set(referrerPolicy) + +const requestRedirect = ['follow', 'manual', 'error'] + +const safeMethods = ['GET', 'HEAD', 'OPTIONS', 'TRACE'] +const safeMethodsSet = new Set(safeMethods) + +const requestMode = ['navigate', 'same-origin', 'no-cors', 'cors'] + +const requestCredentials = ['omit', 'same-origin', 'include'] + +const requestCache = [ + 'default', + 'no-store', + 'reload', + 'no-cache', + 'force-cache', + 'only-if-cached' +] + +// https://fetch.spec.whatwg.org/#request-body-header-name +const requestBodyHeader = [ + 'content-encoding', + 'content-language', + 'content-location', + 'content-type', + // See https://github.com/nodejs/undici/issues/2021 + // 'Content-Length' is a forbidden header name, which is typically + // removed in the Headers implementation. However, undici doesn't + // filter out headers, so we add it here. + 'content-length' +] + +// https://fetch.spec.whatwg.org/#enumdef-requestduplex +const requestDuplex = [ + 'half' +] + +// http://fetch.spec.whatwg.org/#forbidden-method +const forbiddenMethods = ['CONNECT', 'TRACE', 'TRACK'] +const forbiddenMethodsSet = new Set(forbiddenMethods) + +const subresource = [ + 'audio', + 'audioworklet', + 'font', + 'image', + 'manifest', + 'paintworklet', + 'script', + 'style', + 'track', + 'video', + 'xslt', + '' +] +const subresourceSet = new Set(subresource) + +/** @type {globalThis['DOMException']} */ +const DOMException = globalThis.DOMException ?? (() => { + // DOMException was only made a global in Node v17.0.0, + // but fetch supports >= v16.8. + try { + atob('~') + } catch (err) { + return Object.getPrototypeOf(err).constructor + } +})() + +let channel + +/** @type {globalThis['structuredClone']} */ +const structuredClone = + globalThis.structuredClone ?? + // https://github.com/nodejs/node/blob/b27ae24dcc4251bad726d9d84baf678d1f707fed/lib/internal/structured_clone.js + // structuredClone was added in v17.0.0, but fetch supports v16.8 + function structuredClone (value, options = undefined) { + if (arguments.length === 0) { + throw new TypeError('missing argument') + } + + if (!channel) { + channel = new MessageChannel() + } + channel.port1.unref() + channel.port2.unref() + channel.port1.postMessage(value, options?.transfer) + return receiveMessageOnPort(channel.port2).message + } + +module.exports = { + DOMException, + structuredClone, + subresource, + forbiddenMethods, + requestBodyHeader, + referrerPolicy, + requestRedirect, + requestMode, + requestCredentials, + requestCache, + redirectStatus, + corsSafeListedMethods, + nullBodyStatus, + safeMethods, + badPorts, + requestDuplex, + subresourceSet, + badPortsSet, + redirectStatusSet, + corsSafeListedMethodsSet, + safeMethodsSet, + forbiddenMethodsSet, + referrerPolicySet +} diff --git a/lib/fetch/dataURL.js b/lib/fetch/dataURL.js new file mode 100644 index 0000000..7b6a606 --- /dev/null +++ b/lib/fetch/dataURL.js @@ -0,0 +1,627 @@ +const assert = require('assert') +const { atob } = require('buffer') +const { isomorphicDecode } = require('./util') + +const encoder = new TextEncoder() + +/** + * @see https://mimesniff.spec.whatwg.org/#http-token-code-point + */ +const HTTP_TOKEN_CODEPOINTS = /^[!#$%&'*+-.^_|~A-Za-z0-9]+$/ +const HTTP_WHITESPACE_REGEX = /(\u000A|\u000D|\u0009|\u0020)/ // eslint-disable-line +/** + * @see https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point + */ +const HTTP_QUOTED_STRING_TOKENS = /[\u0009|\u0020-\u007E|\u0080-\u00FF]/ // eslint-disable-line + +// https://fetch.spec.whatwg.org/#data-url-processor +/** @param {URL} dataURL */ +function dataURLProcessor (dataURL) { + // 1. Assert: dataURL’s scheme is "data". + assert(dataURL.protocol === 'data:') + + // 2. Let input be the result of running the URL + // serializer on dataURL with exclude fragment + // set to true. + let input = URLSerializer(dataURL, true) + + // 3. Remove the leading "data:" string from input. + input = input.slice(5) + + // 4. Let position point at the start of input. + const position = { position: 0 } + + // 5. Let mimeType be the result of collecting a + // sequence of code points that are not equal + // to U+002C (,), given position. + let mimeType = collectASequenceOfCodePointsFast( + ',', + input, + position + ) + + // 6. Strip leading and trailing ASCII whitespace + // from mimeType. + // Undici implementation note: we need to store the + // length because if the mimetype has spaces removed, + // the wrong amount will be sliced from the input in + // step #9 + const mimeTypeLength = mimeType.length + mimeType = removeASCIIWhitespace(mimeType, true, true) + + // 7. If position is past the end of input, then + // return failure + if (position.position >= input.length) { + return 'failure' + } + + // 8. Advance position by 1. + position.position++ + + // 9. Let encodedBody be the remainder of input. + const encodedBody = input.slice(mimeTypeLength + 1) + + // 10. Let body be the percent-decoding of encodedBody. + let body = stringPercentDecode(encodedBody) + + // 11. If mimeType ends with U+003B (;), followed by + // zero or more U+0020 SPACE, followed by an ASCII + // case-insensitive match for "base64", then: + if (/;(\u0020){0,}base64$/i.test(mimeType)) { + // 1. Let stringBody be the isomorphic decode of body. + const stringBody = isomorphicDecode(body) + + // 2. Set body to the forgiving-base64 decode of + // stringBody. + body = forgivingBase64(stringBody) + + // 3. If body is failure, then return failure. + if (body === 'failure') { + return 'failure' + } + + // 4. Remove the last 6 code points from mimeType. + mimeType = mimeType.slice(0, -6) + + // 5. Remove trailing U+0020 SPACE code points from mimeType, + // if any. + mimeType = mimeType.replace(/(\u0020)+$/, '') + + // 6. Remove the last U+003B (;) code point from mimeType. + mimeType = mimeType.slice(0, -1) + } + + // 12. If mimeType starts with U+003B (;), then prepend + // "text/plain" to mimeType. + if (mimeType.startsWith(';')) { + mimeType = 'text/plain' + mimeType + } + + // 13. Let mimeTypeRecord be the result of parsing + // mimeType. + let mimeTypeRecord = parseMIMEType(mimeType) + + // 14. If mimeTypeRecord is failure, then set + // mimeTypeRecord to text/plain;charset=US-ASCII. + if (mimeTypeRecord === 'failure') { + mimeTypeRecord = parseMIMEType('text/plain;charset=US-ASCII') + } + + // 15. Return a new data: URL struct whose MIME + // type is mimeTypeRecord and body is body. + // https://fetch.spec.whatwg.org/#data-url-struct + return { mimeType: mimeTypeRecord, body } +} + +// https://url.spec.whatwg.org/#concept-url-serializer +/** + * @param {URL} url + * @param {boolean} excludeFragment + */ +function URLSerializer (url, excludeFragment = false) { + if (!excludeFragment) { + return url.href + } + + const href = url.href + const hashLength = url.hash.length + + return hashLength === 0 ? href : href.substring(0, href.length - hashLength) +} + +// https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points +/** + * @param {(char: string) => boolean} condition + * @param {string} input + * @param {{ position: number }} position + */ +function collectASequenceOfCodePoints (condition, input, position) { + // 1. Let result be the empty string. + let result = '' + + // 2. While position doesn’t point past the end of input and the + // code point at position within input meets the condition condition: + while (position.position < input.length && condition(input[position.position])) { + // 1. Append that code point to the end of result. + result += input[position.position] + + // 2. Advance position by 1. + position.position++ + } + + // 3. Return result. + return result +} + +/** + * A faster collectASequenceOfCodePoints that only works when comparing a single character. + * @param {string} char + * @param {string} input + * @param {{ position: number }} position + */ +function collectASequenceOfCodePointsFast (char, input, position) { + const idx = input.indexOf(char, position.position) + const start = position.position + + if (idx === -1) { + position.position = input.length + return input.slice(start) + } + + position.position = idx + return input.slice(start, position.position) +} + +// https://url.spec.whatwg.org/#string-percent-decode +/** @param {string} input */ +function stringPercentDecode (input) { + // 1. Let bytes be the UTF-8 encoding of input. + const bytes = encoder.encode(input) + + // 2. Return the percent-decoding of bytes. + return percentDecode(bytes) +} + +// https://url.spec.whatwg.org/#percent-decode +/** @param {Uint8Array} input */ +function percentDecode (input) { + // 1. Let output be an empty byte sequence. + /** @type {number[]} */ + const output = [] + + // 2. For each byte byte in input: + for (let i = 0; i < input.length; i++) { + const byte = input[i] + + // 1. If byte is not 0x25 (%), then append byte to output. + if (byte !== 0x25) { + output.push(byte) + + // 2. Otherwise, if byte is 0x25 (%) and the next two bytes + // after byte in input are not in the ranges + // 0x30 (0) to 0x39 (9), 0x41 (A) to 0x46 (F), + // and 0x61 (a) to 0x66 (f), all inclusive, append byte + // to output. + } else if ( + byte === 0x25 && + !/^[0-9A-Fa-f]{2}$/i.test(String.fromCharCode(input[i + 1], input[i + 2])) + ) { + output.push(0x25) + + // 3. Otherwise: + } else { + // 1. Let bytePoint be the two bytes after byte in input, + // decoded, and then interpreted as hexadecimal number. + const nextTwoBytes = String.fromCharCode(input[i + 1], input[i + 2]) + const bytePoint = Number.parseInt(nextTwoBytes, 16) + + // 2. Append a byte whose value is bytePoint to output. + output.push(bytePoint) + + // 3. Skip the next two bytes in input. + i += 2 + } + } + + // 3. Return output. + return Uint8Array.from(output) +} + +// https://mimesniff.spec.whatwg.org/#parse-a-mime-type +/** @param {string} input */ +function parseMIMEType (input) { + // 1. Remove any leading and trailing HTTP whitespace + // from input. + input = removeHTTPWhitespace(input, true, true) + + // 2. Let position be a position variable for input, + // initially pointing at the start of input. + const position = { position: 0 } + + // 3. Let type be the result of collecting a sequence + // of code points that are not U+002F (/) from + // input, given position. + const type = collectASequenceOfCodePointsFast( + '/', + input, + position + ) + + // 4. If type is the empty string or does not solely + // contain HTTP token code points, then return failure. + // https://mimesniff.spec.whatwg.org/#http-token-code-point + if (type.length === 0 || !HTTP_TOKEN_CODEPOINTS.test(type)) { + return 'failure' + } + + // 5. If position is past the end of input, then return + // failure + if (position.position > input.length) { + return 'failure' + } + + // 6. Advance position by 1. (This skips past U+002F (/).) + position.position++ + + // 7. Let subtype be the result of collecting a sequence of + // code points that are not U+003B (;) from input, given + // position. + let subtype = collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 8. Remove any trailing HTTP whitespace from subtype. + subtype = removeHTTPWhitespace(subtype, false, true) + + // 9. If subtype is the empty string or does not solely + // contain HTTP token code points, then return failure. + if (subtype.length === 0 || !HTTP_TOKEN_CODEPOINTS.test(subtype)) { + return 'failure' + } + + const typeLowercase = type.toLowerCase() + const subtypeLowercase = subtype.toLowerCase() + + // 10. Let mimeType be a new MIME type record whose type + // is type, in ASCII lowercase, and subtype is subtype, + // in ASCII lowercase. + // https://mimesniff.spec.whatwg.org/#mime-type + const mimeType = { + type: typeLowercase, + subtype: subtypeLowercase, + /** @type {Map} */ + parameters: new Map(), + // https://mimesniff.spec.whatwg.org/#mime-type-essence + essence: `${typeLowercase}/${subtypeLowercase}` + } + + // 11. While position is not past the end of input: + while (position.position < input.length) { + // 1. Advance position by 1. (This skips past U+003B (;).) + position.position++ + + // 2. Collect a sequence of code points that are HTTP + // whitespace from input given position. + collectASequenceOfCodePoints( + // https://fetch.spec.whatwg.org/#http-whitespace + char => HTTP_WHITESPACE_REGEX.test(char), + input, + position + ) + + // 3. Let parameterName be the result of collecting a + // sequence of code points that are not U+003B (;) + // or U+003D (=) from input, given position. + let parameterName = collectASequenceOfCodePoints( + (char) => char !== ';' && char !== '=', + input, + position + ) + + // 4. Set parameterName to parameterName, in ASCII + // lowercase. + parameterName = parameterName.toLowerCase() + + // 5. If position is not past the end of input, then: + if (position.position < input.length) { + // 1. If the code point at position within input is + // U+003B (;), then continue. + if (input[position.position] === ';') { + continue + } + + // 2. Advance position by 1. (This skips past U+003D (=).) + position.position++ + } + + // 6. If position is past the end of input, then break. + if (position.position > input.length) { + break + } + + // 7. Let parameterValue be null. + let parameterValue = null + + // 8. If the code point at position within input is + // U+0022 ("), then: + if (input[position.position] === '"') { + // 1. Set parameterValue to the result of collecting + // an HTTP quoted string from input, given position + // and the extract-value flag. + parameterValue = collectAnHTTPQuotedString(input, position, true) + + // 2. Collect a sequence of code points that are not + // U+003B (;) from input, given position. + collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 9. Otherwise: + } else { + // 1. Set parameterValue to the result of collecting + // a sequence of code points that are not U+003B (;) + // from input, given position. + parameterValue = collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 2. Remove any trailing HTTP whitespace from parameterValue. + parameterValue = removeHTTPWhitespace(parameterValue, false, true) + + // 3. If parameterValue is the empty string, then continue. + if (parameterValue.length === 0) { + continue + } + } + + // 10. If all of the following are true + // - parameterName is not the empty string + // - parameterName solely contains HTTP token code points + // - parameterValue solely contains HTTP quoted-string token code points + // - mimeType’s parameters[parameterName] does not exist + // then set mimeType’s parameters[parameterName] to parameterValue. + if ( + parameterName.length !== 0 && + HTTP_TOKEN_CODEPOINTS.test(parameterName) && + (parameterValue.length === 0 || HTTP_QUOTED_STRING_TOKENS.test(parameterValue)) && + !mimeType.parameters.has(parameterName) + ) { + mimeType.parameters.set(parameterName, parameterValue) + } + } + + // 12. Return mimeType. + return mimeType +} + +// https://infra.spec.whatwg.org/#forgiving-base64-decode +/** @param {string} data */ +function forgivingBase64 (data) { + // 1. Remove all ASCII whitespace from data. + data = data.replace(/[\u0009\u000A\u000C\u000D\u0020]/g, '') // eslint-disable-line + + // 2. If data’s code point length divides by 4 leaving + // no remainder, then: + if (data.length % 4 === 0) { + // 1. If data ends with one or two U+003D (=) code points, + // then remove them from data. + data = data.replace(/=?=$/, '') + } + + // 3. If data’s code point length divides by 4 leaving + // a remainder of 1, then return failure. + if (data.length % 4 === 1) { + return 'failure' + } + + // 4. If data contains a code point that is not one of + // U+002B (+) + // U+002F (/) + // ASCII alphanumeric + // then return failure. + if (/[^+/0-9A-Za-z]/.test(data)) { + return 'failure' + } + + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + + for (let byte = 0; byte < binary.length; byte++) { + bytes[byte] = binary.charCodeAt(byte) + } + + return bytes +} + +// https://fetch.spec.whatwg.org/#collect-an-http-quoted-string +// tests: https://fetch.spec.whatwg.org/#example-http-quoted-string +/** + * @param {string} input + * @param {{ position: number }} position + * @param {boolean?} extractValue + */ +function collectAnHTTPQuotedString (input, position, extractValue) { + // 1. Let positionStart be position. + const positionStart = position.position + + // 2. Let value be the empty string. + let value = '' + + // 3. Assert: the code point at position within input + // is U+0022 ("). + assert(input[position.position] === '"') + + // 4. Advance position by 1. + position.position++ + + // 5. While true: + while (true) { + // 1. Append the result of collecting a sequence of code points + // that are not U+0022 (") or U+005C (\) from input, given + // position, to value. + value += collectASequenceOfCodePoints( + (char) => char !== '"' && char !== '\\', + input, + position + ) + + // 2. If position is past the end of input, then break. + if (position.position >= input.length) { + break + } + + // 3. Let quoteOrBackslash be the code point at position within + // input. + const quoteOrBackslash = input[position.position] + + // 4. Advance position by 1. + position.position++ + + // 5. If quoteOrBackslash is U+005C (\), then: + if (quoteOrBackslash === '\\') { + // 1. If position is past the end of input, then append + // U+005C (\) to value and break. + if (position.position >= input.length) { + value += '\\' + break + } + + // 2. Append the code point at position within input to value. + value += input[position.position] + + // 3. Advance position by 1. + position.position++ + + // 6. Otherwise: + } else { + // 1. Assert: quoteOrBackslash is U+0022 ("). + assert(quoteOrBackslash === '"') + + // 2. Break. + break + } + } + + // 6. If the extract-value flag is set, then return value. + if (extractValue) { + return value + } + + // 7. Return the code points from positionStart to position, + // inclusive, within input. + return input.slice(positionStart, position.position) +} + +/** + * @see https://mimesniff.spec.whatwg.org/#serialize-a-mime-type + */ +function serializeAMimeType (mimeType) { + assert(mimeType !== 'failure') + const { parameters, essence } = mimeType + + // 1. Let serialization be the concatenation of mimeType’s + // type, U+002F (/), and mimeType’s subtype. + let serialization = essence + + // 2. For each name → value of mimeType’s parameters: + for (let [name, value] of parameters.entries()) { + // 1. Append U+003B (;) to serialization. + serialization += ';' + + // 2. Append name to serialization. + serialization += name + + // 3. Append U+003D (=) to serialization. + serialization += '=' + + // 4. If value does not solely contain HTTP token code + // points or value is the empty string, then: + if (!HTTP_TOKEN_CODEPOINTS.test(value)) { + // 1. Precede each occurence of U+0022 (") or + // U+005C (\) in value with U+005C (\). + value = value.replace(/(\\|")/g, '\\$1') + + // 2. Prepend U+0022 (") to value. + value = '"' + value + + // 3. Append U+0022 (") to value. + value += '"' + } + + // 5. Append value to serialization. + serialization += value + } + + // 3. Return serialization. + return serialization +} + +/** + * @see https://fetch.spec.whatwg.org/#http-whitespace + * @param {string} char + */ +function isHTTPWhiteSpace (char) { + return char === '\r' || char === '\n' || char === '\t' || char === ' ' +} + +/** + * @see https://fetch.spec.whatwg.org/#http-whitespace + * @param {string} str + */ +function removeHTTPWhitespace (str, leading = true, trailing = true) { + let lead = 0 + let trail = str.length - 1 + + if (leading) { + for (; lead < str.length && isHTTPWhiteSpace(str[lead]); lead++); + } + + if (trailing) { + for (; trail > 0 && isHTTPWhiteSpace(str[trail]); trail--); + } + + return str.slice(lead, trail + 1) +} + +/** + * @see https://infra.spec.whatwg.org/#ascii-whitespace + * @param {string} char + */ +function isASCIIWhitespace (char) { + return char === '\r' || char === '\n' || char === '\t' || char === '\f' || char === ' ' +} + +/** + * @see https://infra.spec.whatwg.org/#strip-leading-and-trailing-ascii-whitespace + */ +function removeASCIIWhitespace (str, leading = true, trailing = true) { + let lead = 0 + let trail = str.length - 1 + + if (leading) { + for (; lead < str.length && isASCIIWhitespace(str[lead]); lead++); + } + + if (trailing) { + for (; trail > 0 && isASCIIWhitespace(str[trail]); trail--); + } + + return str.slice(lead, trail + 1) +} + +module.exports = { + dataURLProcessor, + URLSerializer, + collectASequenceOfCodePoints, + collectASequenceOfCodePointsFast, + stringPercentDecode, + parseMIMEType, + collectAnHTTPQuotedString, + serializeAMimeType +} diff --git a/lib/fetch/file.js b/lib/fetch/file.js new file mode 100644 index 0000000..3133d25 --- /dev/null +++ b/lib/fetch/file.js @@ -0,0 +1,344 @@ +'use strict' + +const { Blob, File: NativeFile } = require('buffer') +const { types } = require('util') +const { kState } = require('./symbols') +const { isBlobLike } = require('./util') +const { webidl } = require('./webidl') +const { parseMIMEType, serializeAMimeType } = require('./dataURL') +const { kEnumerableProperty } = require('../core/util') +const encoder = new TextEncoder() + +class File extends Blob { + constructor (fileBits, fileName, options = {}) { + // The File constructor is invoked with two or three parameters, depending + // on whether the optional dictionary parameter is used. When the File() + // constructor is invoked, user agents must run the following steps: + webidl.argumentLengthCheck(arguments, 2, { header: 'File constructor' }) + + fileBits = webidl.converters['sequence'](fileBits) + fileName = webidl.converters.USVString(fileName) + options = webidl.converters.FilePropertyBag(options) + + // 1. Let bytes be the result of processing blob parts given fileBits and + // options. + // Note: Blob handles this for us + + // 2. Let n be the fileName argument to the constructor. + const n = fileName + + // 3. Process FilePropertyBag dictionary argument by running the following + // substeps: + + // 1. If the type member is provided and is not the empty string, let t + // be set to the type dictionary member. If t contains any characters + // outside the range U+0020 to U+007E, then set t to the empty string + // and return from these substeps. + // 2. Convert every character in t to ASCII lowercase. + let t = options.type + let d + + // eslint-disable-next-line no-labels + substep: { + if (t) { + t = parseMIMEType(t) + + if (t === 'failure') { + t = '' + // eslint-disable-next-line no-labels + break substep + } + + t = serializeAMimeType(t).toLowerCase() + } + + // 3. If the lastModified member is provided, let d be set to the + // lastModified dictionary member. If it is not provided, set d to the + // current date and time represented as the number of milliseconds since + // the Unix Epoch (which is the equivalent of Date.now() [ECMA-262]). + d = options.lastModified + } + + // 4. Return a new File object F such that: + // F refers to the bytes byte sequence. + // F.size is set to the number of total bytes in bytes. + // F.name is set to n. + // F.type is set to t. + // F.lastModified is set to d. + + super(processBlobParts(fileBits, options), { type: t }) + this[kState] = { + name: n, + lastModified: d, + type: t + } + } + + get name () { + webidl.brandCheck(this, File) + + return this[kState].name + } + + get lastModified () { + webidl.brandCheck(this, File) + + return this[kState].lastModified + } + + get type () { + webidl.brandCheck(this, File) + + return this[kState].type + } +} + +class FileLike { + constructor (blobLike, fileName, options = {}) { + // TODO: argument idl type check + + // The File constructor is invoked with two or three parameters, depending + // on whether the optional dictionary parameter is used. When the File() + // constructor is invoked, user agents must run the following steps: + + // 1. Let bytes be the result of processing blob parts given fileBits and + // options. + + // 2. Let n be the fileName argument to the constructor. + const n = fileName + + // 3. Process FilePropertyBag dictionary argument by running the following + // substeps: + + // 1. If the type member is provided and is not the empty string, let t + // be set to the type dictionary member. If t contains any characters + // outside the range U+0020 to U+007E, then set t to the empty string + // and return from these substeps. + // TODO + const t = options.type + + // 2. Convert every character in t to ASCII lowercase. + // TODO + + // 3. If the lastModified member is provided, let d be set to the + // lastModified dictionary member. If it is not provided, set d to the + // current date and time represented as the number of milliseconds since + // the Unix Epoch (which is the equivalent of Date.now() [ECMA-262]). + const d = options.lastModified ?? Date.now() + + // 4. Return a new File object F such that: + // F refers to the bytes byte sequence. + // F.size is set to the number of total bytes in bytes. + // F.name is set to n. + // F.type is set to t. + // F.lastModified is set to d. + + this[kState] = { + blobLike, + name: n, + type: t, + lastModified: d + } + } + + stream (...args) { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.stream(...args) + } + + arrayBuffer (...args) { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.arrayBuffer(...args) + } + + slice (...args) { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.slice(...args) + } + + text (...args) { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.text(...args) + } + + get size () { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.size + } + + get type () { + webidl.brandCheck(this, FileLike) + + return this[kState].blobLike.type + } + + get name () { + webidl.brandCheck(this, FileLike) + + return this[kState].name + } + + get lastModified () { + webidl.brandCheck(this, FileLike) + + return this[kState].lastModified + } + + get [Symbol.toStringTag] () { + return 'File' + } +} + +Object.defineProperties(File.prototype, { + [Symbol.toStringTag]: { + value: 'File', + configurable: true + }, + name: kEnumerableProperty, + lastModified: kEnumerableProperty +}) + +webidl.converters.Blob = webidl.interfaceConverter(Blob) + +webidl.converters.BlobPart = function (V, opts) { + if (webidl.util.Type(V) === 'Object') { + if (isBlobLike(V)) { + return webidl.converters.Blob(V, { strict: false }) + } + + if ( + ArrayBuffer.isView(V) || + types.isAnyArrayBuffer(V) + ) { + return webidl.converters.BufferSource(V, opts) + } + } + + return webidl.converters.USVString(V, opts) +} + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.BlobPart +) + +// https://www.w3.org/TR/FileAPI/#dfn-FilePropertyBag +webidl.converters.FilePropertyBag = webidl.dictionaryConverter([ + { + key: 'lastModified', + converter: webidl.converters['long long'], + get defaultValue () { + return Date.now() + } + }, + { + key: 'type', + converter: webidl.converters.DOMString, + defaultValue: '' + }, + { + key: 'endings', + converter: (value) => { + value = webidl.converters.DOMString(value) + value = value.toLowerCase() + + if (value !== 'native') { + value = 'transparent' + } + + return value + }, + defaultValue: 'transparent' + } +]) + +/** + * @see https://www.w3.org/TR/FileAPI/#process-blob-parts + * @param {(NodeJS.TypedArray|Blob|string)[]} parts + * @param {{ type: string, endings: string }} options + */ +function processBlobParts (parts, options) { + // 1. Let bytes be an empty sequence of bytes. + /** @type {NodeJS.TypedArray[]} */ + const bytes = [] + + // 2. For each element in parts: + for (const element of parts) { + // 1. If element is a USVString, run the following substeps: + if (typeof element === 'string') { + // 1. Let s be element. + let s = element + + // 2. If the endings member of options is "native", set s + // to the result of converting line endings to native + // of element. + if (options.endings === 'native') { + s = convertLineEndingsNative(s) + } + + // 3. Append the result of UTF-8 encoding s to bytes. + bytes.push(encoder.encode(s)) + } else if ( + types.isAnyArrayBuffer(element) || + types.isTypedArray(element) + ) { + // 2. If element is a BufferSource, get a copy of the + // bytes held by the buffer source, and append those + // bytes to bytes. + if (!element.buffer) { // ArrayBuffer + bytes.push(new Uint8Array(element)) + } else { + bytes.push( + new Uint8Array(element.buffer, element.byteOffset, element.byteLength) + ) + } + } else if (isBlobLike(element)) { + // 3. If element is a Blob, append the bytes it represents + // to bytes. + bytes.push(element) + } + } + + // 3. Return bytes. + return bytes +} + +/** + * @see https://www.w3.org/TR/FileAPI/#convert-line-endings-to-native + * @param {string} s + */ +function convertLineEndingsNative (s) { + // 1. Let native line ending be be the code point U+000A LF. + let nativeLineEnding = '\n' + + // 2. If the underlying platform’s conventions are to + // represent newlines as a carriage return and line feed + // sequence, set native line ending to the code point + // U+000D CR followed by the code point U+000A LF. + if (process.platform === 'win32') { + nativeLineEnding = '\r\n' + } + + return s.replace(/\r?\n/g, nativeLineEnding) +} + +// If this function is moved to ./util.js, some tools (such as +// rollup) will warn about circular dependencies. See: +// https://github.com/nodejs/undici/issues/1629 +function isFileLike (object) { + return ( + (NativeFile && object instanceof NativeFile) || + object instanceof File || ( + object && + (typeof object.stream === 'function' || + typeof object.arrayBuffer === 'function') && + object[Symbol.toStringTag] === 'File' + ) + ) +} + +module.exports = { File, FileLike, isFileLike } diff --git a/lib/fetch/formdata.js b/lib/fetch/formdata.js new file mode 100644 index 0000000..5975e26 --- /dev/null +++ b/lib/fetch/formdata.js @@ -0,0 +1,265 @@ +'use strict' + +const { isBlobLike, toUSVString, makeIterator } = require('./util') +const { kState } = require('./symbols') +const { File: UndiciFile, FileLike, isFileLike } = require('./file') +const { webidl } = require('./webidl') +const { Blob, File: NativeFile } = require('buffer') + +/** @type {globalThis['File']} */ +const File = NativeFile ?? UndiciFile + +// https://xhr.spec.whatwg.org/#formdata +class FormData { + constructor (form) { + if (form !== undefined) { + throw webidl.errors.conversionFailed({ + prefix: 'FormData constructor', + argument: 'Argument 1', + types: ['undefined'] + }) + } + + this[kState] = [] + } + + append (name, value, filename = undefined) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 2, { header: 'FormData.append' }) + + if (arguments.length === 3 && !isBlobLike(value)) { + throw new TypeError( + "Failed to execute 'append' on 'FormData': parameter 2 is not of type 'Blob'" + ) + } + + // 1. Let value be value if given; otherwise blobValue. + + name = webidl.converters.USVString(name) + value = isBlobLike(value) + ? webidl.converters.Blob(value, { strict: false }) + : webidl.converters.USVString(value) + filename = arguments.length === 3 + ? webidl.converters.USVString(filename) + : undefined + + // 2. Let entry be the result of creating an entry with + // name, value, and filename if given. + const entry = makeEntry(name, value, filename) + + // 3. Append entry to this’s entry list. + this[kState].push(entry) + } + + delete (name) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FormData.delete' }) + + name = webidl.converters.USVString(name) + + // The delete(name) method steps are to remove all entries whose name + // is name from this’s entry list. + this[kState] = this[kState].filter(entry => entry.name !== name) + } + + get (name) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FormData.get' }) + + name = webidl.converters.USVString(name) + + // 1. If there is no entry whose name is name in this’s entry list, + // then return null. + const idx = this[kState].findIndex((entry) => entry.name === name) + if (idx === -1) { + return null + } + + // 2. Return the value of the first entry whose name is name from + // this’s entry list. + return this[kState][idx].value + } + + getAll (name) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FormData.getAll' }) + + name = webidl.converters.USVString(name) + + // 1. If there is no entry whose name is name in this’s entry list, + // then return the empty list. + // 2. Return the values of all entries whose name is name, in order, + // from this’s entry list. + return this[kState] + .filter((entry) => entry.name === name) + .map((entry) => entry.value) + } + + has (name) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FormData.has' }) + + name = webidl.converters.USVString(name) + + // The has(name) method steps are to return true if there is an entry + // whose name is name in this’s entry list; otherwise false. + return this[kState].findIndex((entry) => entry.name === name) !== -1 + } + + set (name, value, filename = undefined) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 2, { header: 'FormData.set' }) + + if (arguments.length === 3 && !isBlobLike(value)) { + throw new TypeError( + "Failed to execute 'set' on 'FormData': parameter 2 is not of type 'Blob'" + ) + } + + // The set(name, value) and set(name, blobValue, filename) method steps + // are: + + // 1. Let value be value if given; otherwise blobValue. + + name = webidl.converters.USVString(name) + value = isBlobLike(value) + ? webidl.converters.Blob(value, { strict: false }) + : webidl.converters.USVString(value) + filename = arguments.length === 3 + ? toUSVString(filename) + : undefined + + // 2. Let entry be the result of creating an entry with name, value, and + // filename if given. + const entry = makeEntry(name, value, filename) + + // 3. If there are entries in this’s entry list whose name is name, then + // replace the first such entry with entry and remove the others. + const idx = this[kState].findIndex((entry) => entry.name === name) + if (idx !== -1) { + this[kState] = [ + ...this[kState].slice(0, idx), + entry, + ...this[kState].slice(idx + 1).filter((entry) => entry.name !== name) + ] + } else { + // 4. Otherwise, append entry to this’s entry list. + this[kState].push(entry) + } + } + + entries () { + webidl.brandCheck(this, FormData) + + return makeIterator( + () => this[kState].map(pair => [pair.name, pair.value]), + 'FormData', + 'key+value' + ) + } + + keys () { + webidl.brandCheck(this, FormData) + + return makeIterator( + () => this[kState].map(pair => [pair.name, pair.value]), + 'FormData', + 'key' + ) + } + + values () { + webidl.brandCheck(this, FormData) + + return makeIterator( + () => this[kState].map(pair => [pair.name, pair.value]), + 'FormData', + 'value' + ) + } + + /** + * @param {(value: string, key: string, self: FormData) => void} callbackFn + * @param {unknown} thisArg + */ + forEach (callbackFn, thisArg = globalThis) { + webidl.brandCheck(this, FormData) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FormData.forEach' }) + + if (typeof callbackFn !== 'function') { + throw new TypeError( + "Failed to execute 'forEach' on 'FormData': parameter 1 is not of type 'Function'." + ) + } + + for (const [key, value] of this) { + callbackFn.apply(thisArg, [value, key, this]) + } + } +} + +FormData.prototype[Symbol.iterator] = FormData.prototype.entries + +Object.defineProperties(FormData.prototype, { + [Symbol.toStringTag]: { + value: 'FormData', + configurable: true + } +}) + +/** + * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#create-an-entry + * @param {string} name + * @param {string|Blob} value + * @param {?string} filename + * @returns + */ +function makeEntry (name, value, filename) { + // 1. Set name to the result of converting name into a scalar value string. + // "To convert a string into a scalar value string, replace any surrogates + // with U+FFFD." + // see: https://nodejs.org/dist/latest-v18.x/docs/api/buffer.html#buftostringencoding-start-end + name = Buffer.from(name).toString('utf8') + + // 2. If value is a string, then set value to the result of converting + // value into a scalar value string. + if (typeof value === 'string') { + value = Buffer.from(value).toString('utf8') + } else { + // 3. Otherwise: + + // 1. If value is not a File object, then set value to a new File object, + // representing the same bytes, whose name attribute value is "blob" + if (!isFileLike(value)) { + value = value instanceof Blob + ? new File([value], 'blob', { type: value.type }) + : new FileLike(value, 'blob', { type: value.type }) + } + + // 2. If filename is given, then set value to a new File object, + // representing the same bytes, whose name attribute is filename. + if (filename !== undefined) { + /** @type {FilePropertyBag} */ + const options = { + type: value.type, + lastModified: value.lastModified + } + + value = (NativeFile && value instanceof NativeFile) || value instanceof UndiciFile + ? new File([value], filename, options) + : new FileLike(value, filename, options) + } + } + + // 4. Return an entry whose name is name and whose value is value. + return { name, value } +} + +module.exports = { FormData } diff --git a/lib/fetch/global.js b/lib/fetch/global.js new file mode 100644 index 0000000..1df6f12 --- /dev/null +++ b/lib/fetch/global.js @@ -0,0 +1,40 @@ +'use strict' + +// In case of breaking changes, increase the version +// number to avoid conflicts. +const globalOrigin = Symbol.for('undici.globalOrigin.1') + +function getGlobalOrigin () { + return globalThis[globalOrigin] +} + +function setGlobalOrigin (newOrigin) { + if (newOrigin === undefined) { + Object.defineProperty(globalThis, globalOrigin, { + value: undefined, + writable: true, + enumerable: false, + configurable: false + }) + + return + } + + const parsedURL = new URL(newOrigin) + + if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') { + throw new TypeError(`Only http & https urls are allowed, received ${parsedURL.protocol}`) + } + + Object.defineProperty(globalThis, globalOrigin, { + value: parsedURL, + writable: true, + enumerable: false, + configurable: false + }) +} + +module.exports = { + getGlobalOrigin, + setGlobalOrigin +} diff --git a/lib/fetch/headers.js b/lib/fetch/headers.js new file mode 100644 index 0000000..2f1c0be --- /dev/null +++ b/lib/fetch/headers.js @@ -0,0 +1,589 @@ +// https://github.com/Ethan-Arrowood/undici-fetch + +'use strict' + +const { kHeadersList, kConstruct } = require('../core/symbols') +const { kGuard } = require('./symbols') +const { kEnumerableProperty } = require('../core/util') +const { + makeIterator, + isValidHeaderName, + isValidHeaderValue +} = require('./util') +const { webidl } = require('./webidl') +const assert = require('assert') + +const kHeadersMap = Symbol('headers map') +const kHeadersSortedMap = Symbol('headers map sorted') + +/** + * @param {number} code + */ +function isHTTPWhiteSpaceCharCode (code) { + return code === 0x00a || code === 0x00d || code === 0x009 || code === 0x020 +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-header-value-normalize + * @param {string} potentialValue + */ +function headerValueNormalize (potentialValue) { + // To normalize a byte sequence potentialValue, remove + // any leading and trailing HTTP whitespace bytes from + // potentialValue. + let i = 0; let j = potentialValue.length + + while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(j - 1))) --j + while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(i))) ++i + + return i === 0 && j === potentialValue.length ? potentialValue : potentialValue.substring(i, j) +} + +function fill (headers, object) { + // To fill a Headers object headers with a given object object, run these steps: + + // 1. If object is a sequence, then for each header in object: + // Note: webidl conversion to array has already been done. + if (Array.isArray(object)) { + for (let i = 0; i < object.length; ++i) { + const header = object[i] + // 1. If header does not contain exactly two items, then throw a TypeError. + if (header.length !== 2) { + throw webidl.errors.exception({ + header: 'Headers constructor', + message: `expected name/value pair to be length 2, found ${header.length}.` + }) + } + + // 2. Append (header’s first item, header’s second item) to headers. + appendHeader(headers, header[0], header[1]) + } + } else if (typeof object === 'object' && object !== null) { + // Note: null should throw + + // 2. Otherwise, object is a record, then for each key → value in object, + // append (key, value) to headers + const keys = Object.keys(object) + for (let i = 0; i < keys.length; ++i) { + appendHeader(headers, keys[i], object[keys[i]]) + } + } else { + throw webidl.errors.conversionFailed({ + prefix: 'Headers constructor', + argument: 'Argument 1', + types: ['sequence>', 'record'] + }) + } +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-headers-append + */ +function appendHeader (headers, name, value) { + // 1. Normalize value. + value = headerValueNormalize(value) + + // 2. If name is not a header name or value is not a + // header value, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.append', + value: name, + type: 'header name' + }) + } else if (!isValidHeaderValue(value)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.append', + value, + type: 'header value' + }) + } + + // 3. If headers’s guard is "immutable", then throw a TypeError. + // 4. Otherwise, if headers’s guard is "request" and name is a + // forbidden header name, return. + // Note: undici does not implement forbidden header names + if (headers[kGuard] === 'immutable') { + throw new TypeError('immutable') + } else if (headers[kGuard] === 'request-no-cors') { + // 5. Otherwise, if headers’s guard is "request-no-cors": + // TODO + } + + // 6. Otherwise, if headers’s guard is "response" and name is a + // forbidden response-header name, return. + + // 7. Append (name, value) to headers’s header list. + return headers[kHeadersList].append(name, value) + + // 8. If headers’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from headers +} + +class HeadersList { + /** @type {[string, string][]|null} */ + cookies = null + + constructor (init) { + if (init instanceof HeadersList) { + this[kHeadersMap] = new Map(init[kHeadersMap]) + this[kHeadersSortedMap] = init[kHeadersSortedMap] + this.cookies = init.cookies === null ? null : [...init.cookies] + } else { + this[kHeadersMap] = new Map(init) + this[kHeadersSortedMap] = null + } + } + + // https://fetch.spec.whatwg.org/#header-list-contains + contains (name) { + // A header list list contains a header name name if list + // contains a header whose name is a byte-case-insensitive + // match for name. + name = name.toLowerCase() + + return this[kHeadersMap].has(name) + } + + clear () { + this[kHeadersMap].clear() + this[kHeadersSortedMap] = null + this.cookies = null + } + + // https://fetch.spec.whatwg.org/#concept-header-list-append + append (name, value) { + this[kHeadersSortedMap] = null + + // 1. If list contains name, then set name to the first such + // header’s name. + const lowercaseName = name.toLowerCase() + const exists = this[kHeadersMap].get(lowercaseName) + + // 2. Append (name, value) to list. + if (exists) { + const delimiter = lowercaseName === 'cookie' ? '; ' : ', ' + this[kHeadersMap].set(lowercaseName, { + name: exists.name, + value: `${exists.value}${delimiter}${value}` + }) + } else { + this[kHeadersMap].set(lowercaseName, { name, value }) + } + + if (lowercaseName === 'set-cookie') { + this.cookies ??= [] + this.cookies.push(value) + } + } + + // https://fetch.spec.whatwg.org/#concept-header-list-set + set (name, value) { + this[kHeadersSortedMap] = null + const lowercaseName = name.toLowerCase() + + if (lowercaseName === 'set-cookie') { + this.cookies = [value] + } + + // 1. If list contains name, then set the value of + // the first such header to value and remove the + // others. + // 2. Otherwise, append header (name, value) to list. + this[kHeadersMap].set(lowercaseName, { name, value }) + } + + // https://fetch.spec.whatwg.org/#concept-header-list-delete + delete (name) { + this[kHeadersSortedMap] = null + + name = name.toLowerCase() + + if (name === 'set-cookie') { + this.cookies = null + } + + this[kHeadersMap].delete(name) + } + + // https://fetch.spec.whatwg.org/#concept-header-list-get + get (name) { + const value = this[kHeadersMap].get(name.toLowerCase()) + + // 1. If list does not contain name, then return null. + // 2. Return the values of all headers in list whose name + // is a byte-case-insensitive match for name, + // separated from each other by 0x2C 0x20, in order. + return value === undefined ? null : value.value + } + + * [Symbol.iterator] () { + // use the lowercased name + for (const [name, { value }] of this[kHeadersMap]) { + yield [name, value] + } + } + + get entries () { + const headers = {} + + if (this[kHeadersMap].size) { + for (const { name, value } of this[kHeadersMap].values()) { + headers[name] = value + } + } + + return headers + } +} + +// https://fetch.spec.whatwg.org/#headers-class +class Headers { + constructor (init = undefined) { + if (init === kConstruct) { + return + } + this[kHeadersList] = new HeadersList() + + // The new Headers(init) constructor steps are: + + // 1. Set this’s guard to "none". + this[kGuard] = 'none' + + // 2. If init is given, then fill this with init. + if (init !== undefined) { + init = webidl.converters.HeadersInit(init) + fill(this, init) + } + } + + // https://fetch.spec.whatwg.org/#dom-headers-append + append (name, value) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 2, { header: 'Headers.append' }) + + name = webidl.converters.ByteString(name) + value = webidl.converters.ByteString(value) + + return appendHeader(this, name, value) + } + + // https://fetch.spec.whatwg.org/#dom-headers-delete + delete (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, { header: 'Headers.delete' }) + + name = webidl.converters.ByteString(name) + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.delete', + value: name, + type: 'header name' + }) + } + + // 2. If this’s guard is "immutable", then throw a TypeError. + // 3. Otherwise, if this’s guard is "request" and name is a + // forbidden header name, return. + // 4. Otherwise, if this’s guard is "request-no-cors", name + // is not a no-CORS-safelisted request-header name, and + // name is not a privileged no-CORS request-header name, + // return. + // 5. Otherwise, if this’s guard is "response" and name is + // a forbidden response-header name, return. + // Note: undici does not implement forbidden header names + if (this[kGuard] === 'immutable') { + throw new TypeError('immutable') + } else if (this[kGuard] === 'request-no-cors') { + // TODO + } + + // 6. If this’s header list does not contain name, then + // return. + if (!this[kHeadersList].contains(name)) { + return + } + + // 7. Delete name from this’s header list. + // 8. If this’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from this. + this[kHeadersList].delete(name) + } + + // https://fetch.spec.whatwg.org/#dom-headers-get + get (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, { header: 'Headers.get' }) + + name = webidl.converters.ByteString(name) + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.get', + value: name, + type: 'header name' + }) + } + + // 2. Return the result of getting name from this’s header + // list. + return this[kHeadersList].get(name) + } + + // https://fetch.spec.whatwg.org/#dom-headers-has + has (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, { header: 'Headers.has' }) + + name = webidl.converters.ByteString(name) + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.has', + value: name, + type: 'header name' + }) + } + + // 2. Return true if this’s header list contains name; + // otherwise false. + return this[kHeadersList].contains(name) + } + + // https://fetch.spec.whatwg.org/#dom-headers-set + set (name, value) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 2, { header: 'Headers.set' }) + + name = webidl.converters.ByteString(name) + value = webidl.converters.ByteString(value) + + // 1. Normalize value. + value = headerValueNormalize(value) + + // 2. If name is not a header name or value is not a + // header value, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.set', + value: name, + type: 'header name' + }) + } else if (!isValidHeaderValue(value)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.set', + value, + type: 'header value' + }) + } + + // 3. If this’s guard is "immutable", then throw a TypeError. + // 4. Otherwise, if this’s guard is "request" and name is a + // forbidden header name, return. + // 5. Otherwise, if this’s guard is "request-no-cors" and + // name/value is not a no-CORS-safelisted request-header, + // return. + // 6. Otherwise, if this’s guard is "response" and name is a + // forbidden response-header name, return. + // Note: undici does not implement forbidden header names + if (this[kGuard] === 'immutable') { + throw new TypeError('immutable') + } else if (this[kGuard] === 'request-no-cors') { + // TODO + } + + // 7. Set (name, value) in this’s header list. + // 8. If this’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from this + this[kHeadersList].set(name, value) + } + + // https://fetch.spec.whatwg.org/#dom-headers-getsetcookie + getSetCookie () { + webidl.brandCheck(this, Headers) + + // 1. If this’s header list does not contain `Set-Cookie`, then return « ». + // 2. Return the values of all headers in this’s header list whose name is + // a byte-case-insensitive match for `Set-Cookie`, in order. + + const list = this[kHeadersList].cookies + + if (list) { + return [...list] + } + + return [] + } + + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + get [kHeadersSortedMap] () { + if (this[kHeadersList][kHeadersSortedMap]) { + return this[kHeadersList][kHeadersSortedMap] + } + + // 1. Let headers be an empty list of headers with the key being the name + // and value the value. + const headers = [] + + // 2. Let names be the result of convert header names to a sorted-lowercase + // set with all the names of the headers in list. + const names = [...this[kHeadersList]].sort((a, b) => a[0] < b[0] ? -1 : 1) + const cookies = this[kHeadersList].cookies + + // 3. For each name of names: + for (let i = 0; i < names.length; ++i) { + const [name, value] = names[i] + // 1. If name is `set-cookie`, then: + if (name === 'set-cookie') { + // 1. Let values be a list of all values of headers in list whose name + // is a byte-case-insensitive match for name, in order. + + // 2. For each value of values: + // 1. Append (name, value) to headers. + for (let j = 0; j < cookies.length; ++j) { + headers.push([name, cookies[j]]) + } + } else { + // 2. Otherwise: + + // 1. Let value be the result of getting name from list. + + // 2. Assert: value is non-null. + assert(value !== null) + + // 3. Append (name, value) to headers. + headers.push([name, value]) + } + } + + this[kHeadersList][kHeadersSortedMap] = headers + + // 4. Return headers. + return headers + } + + keys () { + webidl.brandCheck(this, Headers) + + if (this[kGuard] === 'immutable') { + const value = this[kHeadersSortedMap] + return makeIterator(() => value, 'Headers', + 'key') + } + + return makeIterator( + () => [...this[kHeadersSortedMap].values()], + 'Headers', + 'key' + ) + } + + values () { + webidl.brandCheck(this, Headers) + + if (this[kGuard] === 'immutable') { + const value = this[kHeadersSortedMap] + return makeIterator(() => value, 'Headers', + 'value') + } + + return makeIterator( + () => [...this[kHeadersSortedMap].values()], + 'Headers', + 'value' + ) + } + + entries () { + webidl.brandCheck(this, Headers) + + if (this[kGuard] === 'immutable') { + const value = this[kHeadersSortedMap] + return makeIterator(() => value, 'Headers', + 'key+value') + } + + return makeIterator( + () => [...this[kHeadersSortedMap].values()], + 'Headers', + 'key+value' + ) + } + + /** + * @param {(value: string, key: string, self: Headers) => void} callbackFn + * @param {unknown} thisArg + */ + forEach (callbackFn, thisArg = globalThis) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, { header: 'Headers.forEach' }) + + if (typeof callbackFn !== 'function') { + throw new TypeError( + "Failed to execute 'forEach' on 'Headers': parameter 1 is not of type 'Function'." + ) + } + + for (const [key, value] of this) { + callbackFn.apply(thisArg, [value, key, this]) + } + } + + [Symbol.for('nodejs.util.inspect.custom')] () { + webidl.brandCheck(this, Headers) + + return this[kHeadersList] + } +} + +Headers.prototype[Symbol.iterator] = Headers.prototype.entries + +Object.defineProperties(Headers.prototype, { + append: kEnumerableProperty, + delete: kEnumerableProperty, + get: kEnumerableProperty, + has: kEnumerableProperty, + set: kEnumerableProperty, + getSetCookie: kEnumerableProperty, + keys: kEnumerableProperty, + values: kEnumerableProperty, + entries: kEnumerableProperty, + forEach: kEnumerableProperty, + [Symbol.iterator]: { enumerable: false }, + [Symbol.toStringTag]: { + value: 'Headers', + configurable: true + } +}) + +webidl.converters.HeadersInit = function (V) { + if (webidl.util.Type(V) === 'Object') { + if (V[Symbol.iterator]) { + return webidl.converters['sequence>'](V) + } + + return webidl.converters['record'](V) + } + + throw webidl.errors.conversionFailed({ + prefix: 'Headers constructor', + argument: 'Argument 1', + types: ['sequence>', 'record'] + }) +} + +module.exports = { + fill, + Headers, + HeadersList +} diff --git a/lib/fetch/index.js b/lib/fetch/index.js new file mode 100644 index 0000000..dea2069 --- /dev/null +++ b/lib/fetch/index.js @@ -0,0 +1,2148 @@ +// https://github.com/Ethan-Arrowood/undici-fetch + +'use strict' + +const { + Response, + makeNetworkError, + makeAppropriateNetworkError, + filterResponse, + makeResponse +} = require('./response') +const { Headers } = require('./headers') +const { Request, makeRequest } = require('./request') +const zlib = require('zlib') +const { + bytesMatch, + makePolicyContainer, + clonePolicyContainer, + requestBadPort, + TAOCheck, + appendRequestOriginHeader, + responseLocationURL, + requestCurrentURL, + setRequestReferrerPolicyOnRedirect, + tryUpgradeRequestToAPotentiallyTrustworthyURL, + createOpaqueTimingInfo, + appendFetchMetadata, + corsCheck, + crossOriginResourcePolicyCheck, + determineRequestsReferrer, + coarsenedSharedCurrentTime, + createDeferredPromise, + isBlobLike, + sameOrigin, + isCancelled, + isAborted, + isErrorLike, + fullyReadBody, + readableStreamClose, + isomorphicEncode, + urlIsLocal, + urlIsHttpHttpsScheme, + urlHasHttpsScheme +} = require('./util') +const { kState, kHeaders, kGuard, kRealm } = require('./symbols') +const assert = require('assert') +const { safelyExtractBody } = require('./body') +const { + redirectStatusSet, + nullBodyStatus, + safeMethodsSet, + requestBodyHeader, + subresourceSet, + DOMException +} = require('./constants') +const { kHeadersList } = require('../core/symbols') +const EE = require('events') +const { Readable, pipeline } = require('stream') +const { addAbortListener, isErrored, isReadable, nodeMajor, nodeMinor } = require('../core/util') +const { dataURLProcessor, serializeAMimeType } = require('./dataURL') +const { TransformStream } = require('stream/web') +const { getGlobalDispatcher } = require('../global') +const { webidl } = require('./webidl') +const { STATUS_CODES } = require('http') +const GET_OR_HEAD = ['GET', 'HEAD'] + +/** @type {import('buffer').resolveObjectURL} */ +let resolveObjectURL +let ReadableStream = globalThis.ReadableStream + +class Fetch extends EE { + constructor (dispatcher) { + super() + + this.dispatcher = dispatcher + this.connection = null + this.dump = false + this.state = 'ongoing' + // 2 terminated listeners get added per request, + // but only 1 gets removed. If there are 20 redirects, + // 21 listeners will be added. + // See https://github.com/nodejs/undici/issues/1711 + // TODO (fix): Find and fix root cause for leaked listener. + this.setMaxListeners(21) + } + + terminate (reason) { + if (this.state !== 'ongoing') { + return + } + + this.state = 'terminated' + this.connection?.destroy(reason) + this.emit('terminated', reason) + } + + // https://fetch.spec.whatwg.org/#fetch-controller-abort + abort (error) { + if (this.state !== 'ongoing') { + return + } + + // 1. Set controller’s state to "aborted". + this.state = 'aborted' + + // 2. Let fallbackError be an "AbortError" DOMException. + // 3. Set error to fallbackError if it is not given. + if (!error) { + error = new DOMException('The operation was aborted.', 'AbortError') + } + + // 4. Let serializedError be StructuredSerialize(error). + // If that threw an exception, catch it, and let + // serializedError be StructuredSerialize(fallbackError). + + // 5. Set controller’s serialized abort reason to serializedError. + this.serializedAbortReason = error + + this.connection?.destroy(error) + this.emit('terminated', error) + } +} + +// https://fetch.spec.whatwg.org/#fetch-method +function fetch (input, init = {}) { + webidl.argumentLengthCheck(arguments, 1, { header: 'globalThis.fetch' }) + + // 1. Let p be a new promise. + const p = createDeferredPromise() + + // 2. Let requestObject be the result of invoking the initial value of + // Request as constructor with input and init as arguments. If this throws + // an exception, reject p with it and return p. + let requestObject + + try { + requestObject = new Request(input, init) + } catch (e) { + p.reject(e) + return p.promise + } + + // 3. Let request be requestObject’s request. + const request = requestObject[kState] + + // 4. If requestObject’s signal’s aborted flag is set, then: + if (requestObject.signal.aborted) { + // 1. Abort the fetch() call with p, request, null, and + // requestObject’s signal’s abort reason. + abortFetch(p, request, null, requestObject.signal.reason) + + // 2. Return p. + return p.promise + } + + // 5. Let globalObject be request’s client’s global object. + const globalObject = request.client.globalObject + + // 6. If globalObject is a ServiceWorkerGlobalScope object, then set + // request’s service-workers mode to "none". + if (globalObject?.constructor?.name === 'ServiceWorkerGlobalScope') { + request.serviceWorkers = 'none' + } + + // 7. Let responseObject be null. + let responseObject = null + + // 8. Let relevantRealm be this’s relevant Realm. + const relevantRealm = null + + // 9. Let locallyAborted be false. + let locallyAborted = false + + // 10. Let controller be null. + let controller = null + + // 11. Add the following abort steps to requestObject’s signal: + addAbortListener( + requestObject.signal, + () => { + // 1. Set locallyAborted to true. + locallyAborted = true + + // 2. Assert: controller is non-null. + assert(controller != null) + + // 3. Abort controller with requestObject’s signal’s abort reason. + controller.abort(requestObject.signal.reason) + + // 4. Abort the fetch() call with p, request, responseObject, + // and requestObject’s signal’s abort reason. + abortFetch(p, request, responseObject, requestObject.signal.reason) + } + ) + + // 12. Let handleFetchDone given response response be to finalize and + // report timing with response, globalObject, and "fetch". + const handleFetchDone = (response) => + finalizeAndReportTiming(response, 'fetch') + + // 13. Set controller to the result of calling fetch given request, + // with processResponseEndOfBody set to handleFetchDone, and processResponse + // given response being these substeps: + + const processResponse = (response) => { + // 1. If locallyAborted is true, terminate these substeps. + if (locallyAborted) { + return Promise.resolve() + } + + // 2. If response’s aborted flag is set, then: + if (response.aborted) { + // 1. Let deserializedError be the result of deserialize a serialized + // abort reason given controller’s serialized abort reason and + // relevantRealm. + + // 2. Abort the fetch() call with p, request, responseObject, and + // deserializedError. + + abortFetch(p, request, responseObject, controller.serializedAbortReason) + return Promise.resolve() + } + + // 3. If response is a network error, then reject p with a TypeError + // and terminate these substeps. + if (response.type === 'error') { + p.reject( + Object.assign(new TypeError('fetch failed'), { cause: response.error }) + ) + return Promise.resolve() + } + + // 4. Set responseObject to the result of creating a Response object, + // given response, "immutable", and relevantRealm. + responseObject = new Response() + responseObject[kState] = response + responseObject[kRealm] = relevantRealm + responseObject[kHeaders][kHeadersList] = response.headersList + responseObject[kHeaders][kGuard] = 'immutable' + responseObject[kHeaders][kRealm] = relevantRealm + + // 5. Resolve p with responseObject. + p.resolve(responseObject) + } + + controller = fetching({ + request, + processResponseEndOfBody: handleFetchDone, + processResponse, + dispatcher: init.dispatcher ?? getGlobalDispatcher() // undici + }) + + // 14. Return p. + return p.promise +} + +// https://fetch.spec.whatwg.org/#finalize-and-report-timing +function finalizeAndReportTiming (response, initiatorType = 'other') { + // 1. If response is an aborted network error, then return. + if (response.type === 'error' && response.aborted) { + return + } + + // 2. If response’s URL list is null or empty, then return. + if (!response.urlList?.length) { + return + } + + // 3. Let originalURL be response’s URL list[0]. + const originalURL = response.urlList[0] + + // 4. Let timingInfo be response’s timing info. + let timingInfo = response.timingInfo + + // 5. Let cacheState be response’s cache state. + let cacheState = response.cacheState + + // 6. If originalURL’s scheme is not an HTTP(S) scheme, then return. + if (!urlIsHttpHttpsScheme(originalURL)) { + return + } + + // 7. If timingInfo is null, then return. + if (timingInfo === null) { + return + } + + // 8. If response’s timing allow passed flag is not set, then: + if (!response.timingAllowPassed) { + // 1. Set timingInfo to a the result of creating an opaque timing info for timingInfo. + timingInfo = createOpaqueTimingInfo({ + startTime: timingInfo.startTime + }) + + // 2. Set cacheState to the empty string. + cacheState = '' + } + + // 9. Set timingInfo’s end time to the coarsened shared current time + // given global’s relevant settings object’s cross-origin isolated + // capability. + // TODO: given global’s relevant settings object’s cross-origin isolated + // capability? + timingInfo.endTime = coarsenedSharedCurrentTime() + + // 10. Set response’s timing info to timingInfo. + response.timingInfo = timingInfo + + // 11. Mark resource timing for timingInfo, originalURL, initiatorType, + // global, and cacheState. + markResourceTiming( + timingInfo, + originalURL, + initiatorType, + globalThis, + cacheState + ) +} + +// https://w3c.github.io/resource-timing/#dfn-mark-resource-timing +function markResourceTiming (timingInfo, originalURL, initiatorType, globalThis, cacheState) { + if (nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 2)) { + performance.markResourceTiming(timingInfo, originalURL.href, initiatorType, globalThis, cacheState) + } +} + +// https://fetch.spec.whatwg.org/#abort-fetch +function abortFetch (p, request, responseObject, error) { + // Note: AbortSignal.reason was added in node v17.2.0 + // which would give us an undefined error to reject with. + // Remove this once node v16 is no longer supported. + if (!error) { + error = new DOMException('The operation was aborted.', 'AbortError') + } + + // 1. Reject promise with error. + p.reject(error) + + // 2. If request’s body is not null and is readable, then cancel request’s + // body with error. + if (request.body != null && isReadable(request.body?.stream)) { + request.body.stream.cancel(error).catch((err) => { + if (err.code === 'ERR_INVALID_STATE') { + // Node bug? + return + } + throw err + }) + } + + // 3. If responseObject is null, then return. + if (responseObject == null) { + return + } + + // 4. Let response be responseObject’s response. + const response = responseObject[kState] + + // 5. If response’s body is not null and is readable, then error response’s + // body with error. + if (response.body != null && isReadable(response.body?.stream)) { + response.body.stream.cancel(error).catch((err) => { + if (err.code === 'ERR_INVALID_STATE') { + // Node bug? + return + } + throw err + }) + } +} + +// https://fetch.spec.whatwg.org/#fetching +function fetching ({ + request, + processRequestBodyChunkLength, + processRequestEndOfBody, + processResponse, + processResponseEndOfBody, + processResponseConsumeBody, + useParallelQueue = false, + dispatcher // undici +}) { + // 1. Let taskDestination be null. + let taskDestination = null + + // 2. Let crossOriginIsolatedCapability be false. + let crossOriginIsolatedCapability = false + + // 3. If request’s client is non-null, then: + if (request.client != null) { + // 1. Set taskDestination to request’s client’s global object. + taskDestination = request.client.globalObject + + // 2. Set crossOriginIsolatedCapability to request’s client’s cross-origin + // isolated capability. + crossOriginIsolatedCapability = + request.client.crossOriginIsolatedCapability + } + + // 4. If useParallelQueue is true, then set taskDestination to the result of + // starting a new parallel queue. + // TODO + + // 5. Let timingInfo be a new fetch timing info whose start time and + // post-redirect start time are the coarsened shared current time given + // crossOriginIsolatedCapability. + const currenTime = coarsenedSharedCurrentTime(crossOriginIsolatedCapability) + const timingInfo = createOpaqueTimingInfo({ + startTime: currenTime + }) + + // 6. Let fetchParams be a new fetch params whose + // request is request, + // timing info is timingInfo, + // process request body chunk length is processRequestBodyChunkLength, + // process request end-of-body is processRequestEndOfBody, + // process response is processResponse, + // process response consume body is processResponseConsumeBody, + // process response end-of-body is processResponseEndOfBody, + // task destination is taskDestination, + // and cross-origin isolated capability is crossOriginIsolatedCapability. + const fetchParams = { + controller: new Fetch(dispatcher), + request, + timingInfo, + processRequestBodyChunkLength, + processRequestEndOfBody, + processResponse, + processResponseConsumeBody, + processResponseEndOfBody, + taskDestination, + crossOriginIsolatedCapability + } + + // 7. If request’s body is a byte sequence, then set request’s body to + // request’s body as a body. + // NOTE: Since fetching is only called from fetch, body should already be + // extracted. + assert(!request.body || request.body.stream) + + // 8. If request’s window is "client", then set request’s window to request’s + // client, if request’s client’s global object is a Window object; otherwise + // "no-window". + if (request.window === 'client') { + // TODO: What if request.client is null? + request.window = + request.client?.globalObject?.constructor?.name === 'Window' + ? request.client + : 'no-window' + } + + // 9. If request’s origin is "client", then set request’s origin to request’s + // client’s origin. + if (request.origin === 'client') { + // TODO: What if request.client is null? + request.origin = request.client?.origin + } + + // 10. If all of the following conditions are true: + // TODO + + // 11. If request’s policy container is "client", then: + if (request.policyContainer === 'client') { + // 1. If request’s client is non-null, then set request’s policy + // container to a clone of request’s client’s policy container. [HTML] + if (request.client != null) { + request.policyContainer = clonePolicyContainer( + request.client.policyContainer + ) + } else { + // 2. Otherwise, set request’s policy container to a new policy + // container. + request.policyContainer = makePolicyContainer() + } + } + + // 12. If request’s header list does not contain `Accept`, then: + if (!request.headersList.contains('accept')) { + // 1. Let value be `*/*`. + const value = '*/*' + + // 2. A user agent should set value to the first matching statement, if + // any, switching on request’s destination: + // "document" + // "frame" + // "iframe" + // `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8` + // "image" + // `image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5` + // "style" + // `text/css,*/*;q=0.1` + // TODO + + // 3. Append `Accept`/value to request’s header list. + request.headersList.append('accept', value) + } + + // 13. If request’s header list does not contain `Accept-Language`, then + // user agents should append `Accept-Language`/an appropriate value to + // request’s header list. + if (!request.headersList.contains('accept-language')) { + request.headersList.append('accept-language', '*') + } + + // 14. If request’s priority is null, then use request’s initiator and + // destination appropriately in setting request’s priority to a + // user-agent-defined object. + if (request.priority === null) { + // TODO + } + + // 15. If request is a subresource request, then: + if (subresourceSet.has(request.destination)) { + // TODO + } + + // 16. Run main fetch given fetchParams. + mainFetch(fetchParams) + .catch(err => { + fetchParams.controller.terminate(err) + }) + + // 17. Return fetchParam's controller + return fetchParams.controller +} + +// https://fetch.spec.whatwg.org/#concept-main-fetch +async function mainFetch (fetchParams, recursive = false) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. If request’s local-URLs-only flag is set and request’s current URL is + // not local, then set response to a network error. + if (request.localURLsOnly && !urlIsLocal(requestCurrentURL(request))) { + response = makeNetworkError('local URLs only') + } + + // 4. Run report Content Security Policy violations for request. + // TODO + + // 5. Upgrade request to a potentially trustworthy URL, if appropriate. + tryUpgradeRequestToAPotentiallyTrustworthyURL(request) + + // 6. If should request be blocked due to a bad port, should fetching request + // be blocked as mixed content, or should request be blocked by Content + // Security Policy returns blocked, then set response to a network error. + if (requestBadPort(request) === 'blocked') { + response = makeNetworkError('bad port') + } + // TODO: should fetching request be blocked as mixed content? + // TODO: should request be blocked by Content Security Policy? + + // 7. If request’s referrer policy is the empty string, then set request’s + // referrer policy to request’s policy container’s referrer policy. + if (request.referrerPolicy === '') { + request.referrerPolicy = request.policyContainer.referrerPolicy + } + + // 8. If request’s referrer is not "no-referrer", then set request’s + // referrer to the result of invoking determine request’s referrer. + if (request.referrer !== 'no-referrer') { + request.referrer = determineRequestsReferrer(request) + } + + // 9. Set request’s current URL’s scheme to "https" if all of the following + // conditions are true: + // - request’s current URL’s scheme is "http" + // - request’s current URL’s host is a domain + // - Matching request’s current URL’s host per Known HSTS Host Domain Name + // Matching results in either a superdomain match with an asserted + // includeSubDomains directive or a congruent match (with or without an + // asserted includeSubDomains directive). [HSTS] + // TODO + + // 10. If recursive is false, then run the remaining steps in parallel. + // TODO + + // 11. If response is null, then set response to the result of running + // the steps corresponding to the first matching statement: + if (response === null) { + response = await (async () => { + const currentURL = requestCurrentURL(request) + + if ( + // - request’s current URL’s origin is same origin with request’s origin, + // and request’s response tainting is "basic" + (sameOrigin(currentURL, request.url) && request.responseTainting === 'basic') || + // request’s current URL’s scheme is "data" + (currentURL.protocol === 'data:') || + // - request’s mode is "navigate" or "websocket" + (request.mode === 'navigate' || request.mode === 'websocket') + ) { + // 1. Set request’s response tainting to "basic". + request.responseTainting = 'basic' + + // 2. Return the result of running scheme fetch given fetchParams. + return await schemeFetch(fetchParams) + } + + // request’s mode is "same-origin" + if (request.mode === 'same-origin') { + // 1. Return a network error. + return makeNetworkError('request mode cannot be "same-origin"') + } + + // request’s mode is "no-cors" + if (request.mode === 'no-cors') { + // 1. If request’s redirect mode is not "follow", then return a network + // error. + if (request.redirect !== 'follow') { + return makeNetworkError( + 'redirect mode cannot be "follow" for "no-cors" request' + ) + } + + // 2. Set request’s response tainting to "opaque". + request.responseTainting = 'opaque' + + // 3. Return the result of running scheme fetch given fetchParams. + return await schemeFetch(fetchParams) + } + + // request’s current URL’s scheme is not an HTTP(S) scheme + if (!urlIsHttpHttpsScheme(requestCurrentURL(request))) { + // Return a network error. + return makeNetworkError('URL scheme must be a HTTP(S) scheme') + } + + // - request’s use-CORS-preflight flag is set + // - request’s unsafe-request flag is set and either request’s method is + // not a CORS-safelisted method or CORS-unsafe request-header names with + // request’s header list is not empty + // 1. Set request’s response tainting to "cors". + // 2. Let corsWithPreflightResponse be the result of running HTTP fetch + // given fetchParams and true. + // 3. If corsWithPreflightResponse is a network error, then clear cache + // entries using request. + // 4. Return corsWithPreflightResponse. + // TODO + + // Otherwise + // 1. Set request’s response tainting to "cors". + request.responseTainting = 'cors' + + // 2. Return the result of running HTTP fetch given fetchParams. + return await httpFetch(fetchParams) + })() + } + + // 12. If recursive is true, then return response. + if (recursive) { + return response + } + + // 13. If response is not a network error and response is not a filtered + // response, then: + if (response.status !== 0 && !response.internalResponse) { + // If request’s response tainting is "cors", then: + if (request.responseTainting === 'cors') { + // 1. Let headerNames be the result of extracting header list values + // given `Access-Control-Expose-Headers` and response’s header list. + // TODO + // 2. If request’s credentials mode is not "include" and headerNames + // contains `*`, then set response’s CORS-exposed header-name list to + // all unique header names in response’s header list. + // TODO + // 3. Otherwise, if headerNames is not null or failure, then set + // response’s CORS-exposed header-name list to headerNames. + // TODO + } + + // Set response to the following filtered response with response as its + // internal response, depending on request’s response tainting: + if (request.responseTainting === 'basic') { + response = filterResponse(response, 'basic') + } else if (request.responseTainting === 'cors') { + response = filterResponse(response, 'cors') + } else if (request.responseTainting === 'opaque') { + response = filterResponse(response, 'opaque') + } else { + assert(false) + } + } + + // 14. Let internalResponse be response, if response is a network error, + // and response’s internal response otherwise. + let internalResponse = + response.status === 0 ? response : response.internalResponse + + // 15. If internalResponse’s URL list is empty, then set it to a clone of + // request’s URL list. + if (internalResponse.urlList.length === 0) { + internalResponse.urlList.push(...request.urlList) + } + + // 16. If request’s timing allow failed flag is unset, then set + // internalResponse’s timing allow passed flag. + if (!request.timingAllowFailed) { + response.timingAllowPassed = true + } + + // 17. If response is not a network error and any of the following returns + // blocked + // - should internalResponse to request be blocked as mixed content + // - should internalResponse to request be blocked by Content Security Policy + // - should internalResponse to request be blocked due to its MIME type + // - should internalResponse to request be blocked due to nosniff + // TODO + + // 18. If response’s type is "opaque", internalResponse’s status is 206, + // internalResponse’s range-requested flag is set, and request’s header + // list does not contain `Range`, then set response and internalResponse + // to a network error. + if ( + response.type === 'opaque' && + internalResponse.status === 206 && + internalResponse.rangeRequested && + !request.headers.contains('range') + ) { + response = internalResponse = makeNetworkError() + } + + // 19. If response is not a network error and either request’s method is + // `HEAD` or `CONNECT`, or internalResponse’s status is a null body status, + // set internalResponse’s body to null and disregard any enqueuing toward + // it (if any). + if ( + response.status !== 0 && + (request.method === 'HEAD' || + request.method === 'CONNECT' || + nullBodyStatus.includes(internalResponse.status)) + ) { + internalResponse.body = null + fetchParams.controller.dump = true + } + + // 20. If request’s integrity metadata is not the empty string, then: + if (request.integrity) { + // 1. Let processBodyError be this step: run fetch finale given fetchParams + // and a network error. + const processBodyError = (reason) => + fetchFinale(fetchParams, makeNetworkError(reason)) + + // 2. If request’s response tainting is "opaque", or response’s body is null, + // then run processBodyError and abort these steps. + if (request.responseTainting === 'opaque' || response.body == null) { + processBodyError(response.error) + return + } + + // 3. Let processBody given bytes be these steps: + const processBody = (bytes) => { + // 1. If bytes do not match request’s integrity metadata, + // then run processBodyError and abort these steps. [SRI] + if (!bytesMatch(bytes, request.integrity)) { + processBodyError('integrity mismatch') + return + } + + // 2. Set response’s body to bytes as a body. + response.body = safelyExtractBody(bytes)[0] + + // 3. Run fetch finale given fetchParams and response. + fetchFinale(fetchParams, response) + } + + // 4. Fully read response’s body given processBody and processBodyError. + await fullyReadBody(response.body, processBody, processBodyError) + } else { + // 21. Otherwise, run fetch finale given fetchParams and response. + fetchFinale(fetchParams, response) + } +} + +// https://fetch.spec.whatwg.org/#concept-scheme-fetch +// given a fetch params fetchParams +function schemeFetch (fetchParams) { + // Note: since the connection is destroyed on redirect, which sets fetchParams to a + // cancelled state, we do not want this condition to trigger *unless* there have been + // no redirects. See https://github.com/nodejs/undici/issues/1776 + // 1. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams) && fetchParams.request.redirectCount === 0) { + return Promise.resolve(makeAppropriateNetworkError(fetchParams)) + } + + // 2. Let request be fetchParams’s request. + const { request } = fetchParams + + const { protocol: scheme } = requestCurrentURL(request) + + // 3. Switch on request’s current URL’s scheme and run the associated steps: + switch (scheme) { + case 'about:': { + // If request’s current URL’s path is the string "blank", then return a new response + // whose status message is `OK`, header list is « (`Content-Type`, `text/html;charset=utf-8`) », + // and body is the empty byte sequence as a body. + + // Otherwise, return a network error. + return Promise.resolve(makeNetworkError('about scheme is not supported')) + } + case 'blob:': { + if (!resolveObjectURL) { + resolveObjectURL = require('buffer').resolveObjectURL + } + + // 1. Let blobURLEntry be request’s current URL’s blob URL entry. + const blobURLEntry = requestCurrentURL(request) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 + // Buffer.resolveObjectURL does not ignore URL queries. + if (blobURLEntry.search.length !== 0) { + return Promise.resolve(makeNetworkError('NetworkError when attempting to fetch resource.')) + } + + const blobURLEntryObject = resolveObjectURL(blobURLEntry.toString()) + + // 2. If request’s method is not `GET`, blobURLEntry is null, or blobURLEntry’s + // object is not a Blob object, then return a network error. + if (request.method !== 'GET' || !isBlobLike(blobURLEntryObject)) { + return Promise.resolve(makeNetworkError('invalid method')) + } + + // 3. Let bodyWithType be the result of safely extracting blobURLEntry’s object. + const bodyWithType = safelyExtractBody(blobURLEntryObject) + + // 4. Let body be bodyWithType’s body. + const body = bodyWithType[0] + + // 5. Let length be body’s length, serialized and isomorphic encoded. + const length = isomorphicEncode(`${body.length}`) + + // 6. Let type be bodyWithType’s type if it is non-null; otherwise the empty byte sequence. + const type = bodyWithType[1] ?? '' + + // 7. Return a new response whose status message is `OK`, header list is + // « (`Content-Length`, length), (`Content-Type`, type) », and body is body. + const response = makeResponse({ + statusText: 'OK', + headersList: [ + ['content-length', { name: 'Content-Length', value: length }], + ['content-type', { name: 'Content-Type', value: type }] + ] + }) + + response.body = body + + return Promise.resolve(response) + } + case 'data:': { + // 1. Let dataURLStruct be the result of running the + // data: URL processor on request’s current URL. + const currentURL = requestCurrentURL(request) + const dataURLStruct = dataURLProcessor(currentURL) + + // 2. If dataURLStruct is failure, then return a + // network error. + if (dataURLStruct === 'failure') { + return Promise.resolve(makeNetworkError('failed to fetch the data URL')) + } + + // 3. Let mimeType be dataURLStruct’s MIME type, serialized. + const mimeType = serializeAMimeType(dataURLStruct.mimeType) + + // 4. Return a response whose status message is `OK`, + // header list is « (`Content-Type`, mimeType) », + // and body is dataURLStruct’s body as a body. + return Promise.resolve(makeResponse({ + statusText: 'OK', + headersList: [ + ['content-type', { name: 'Content-Type', value: mimeType }] + ], + body: safelyExtractBody(dataURLStruct.body)[0] + })) + } + case 'file:': { + // For now, unfortunate as it is, file URLs are left as an exercise for the reader. + // When in doubt, return a network error. + return Promise.resolve(makeNetworkError('not implemented... yet...')) + } + case 'http:': + case 'https:': { + // Return the result of running HTTP fetch given fetchParams. + + return httpFetch(fetchParams) + .catch((err) => makeNetworkError(err)) + } + default: { + return Promise.resolve(makeNetworkError('unknown scheme')) + } + } +} + +// https://fetch.spec.whatwg.org/#finalize-response +function finalizeResponse (fetchParams, response) { + // 1. Set fetchParams’s request’s done flag. + fetchParams.request.done = true + + // 2, If fetchParams’s process response done is not null, then queue a fetch + // task to run fetchParams’s process response done given response, with + // fetchParams’s task destination. + if (fetchParams.processResponseDone != null) { + queueMicrotask(() => fetchParams.processResponseDone(response)) + } +} + +// https://fetch.spec.whatwg.org/#fetch-finale +function fetchFinale (fetchParams, response) { + // 1. If response is a network error, then: + if (response.type === 'error') { + // 1. Set response’s URL list to « fetchParams’s request’s URL list[0] ». + response.urlList = [fetchParams.request.urlList[0]] + + // 2. Set response’s timing info to the result of creating an opaque timing + // info for fetchParams’s timing info. + response.timingInfo = createOpaqueTimingInfo({ + startTime: fetchParams.timingInfo.startTime + }) + } + + // 2. Let processResponseEndOfBody be the following steps: + const processResponseEndOfBody = () => { + // 1. Set fetchParams’s request’s done flag. + fetchParams.request.done = true + + // If fetchParams’s process response end-of-body is not null, + // then queue a fetch task to run fetchParams’s process response + // end-of-body given response with fetchParams’s task destination. + if (fetchParams.processResponseEndOfBody != null) { + queueMicrotask(() => fetchParams.processResponseEndOfBody(response)) + } + } + + // 3. If fetchParams’s process response is non-null, then queue a fetch task + // to run fetchParams’s process response given response, with fetchParams’s + // task destination. + if (fetchParams.processResponse != null) { + queueMicrotask(() => fetchParams.processResponse(response)) + } + + // 4. If response’s body is null, then run processResponseEndOfBody. + if (response.body == null) { + processResponseEndOfBody() + } else { + // 5. Otherwise: + + // 1. Let transformStream be a new a TransformStream. + + // 2. Let identityTransformAlgorithm be an algorithm which, given chunk, + // enqueues chunk in transformStream. + const identityTransformAlgorithm = (chunk, controller) => { + controller.enqueue(chunk) + } + + // 3. Set up transformStream with transformAlgorithm set to identityTransformAlgorithm + // and flushAlgorithm set to processResponseEndOfBody. + const transformStream = new TransformStream({ + start () {}, + transform: identityTransformAlgorithm, + flush: processResponseEndOfBody + }, { + size () { + return 1 + } + }, { + size () { + return 1 + } + }) + + // 4. Set response’s body to the result of piping response’s body through transformStream. + response.body = { stream: response.body.stream.pipeThrough(transformStream) } + } + + // 6. If fetchParams’s process response consume body is non-null, then: + if (fetchParams.processResponseConsumeBody != null) { + // 1. Let processBody given nullOrBytes be this step: run fetchParams’s + // process response consume body given response and nullOrBytes. + const processBody = (nullOrBytes) => fetchParams.processResponseConsumeBody(response, nullOrBytes) + + // 2. Let processBodyError be this step: run fetchParams’s process + // response consume body given response and failure. + const processBodyError = (failure) => fetchParams.processResponseConsumeBody(response, failure) + + // 3. If response’s body is null, then queue a fetch task to run processBody + // given null, with fetchParams’s task destination. + if (response.body == null) { + queueMicrotask(() => processBody(null)) + } else { + // 4. Otherwise, fully read response’s body given processBody, processBodyError, + // and fetchParams’s task destination. + return fullyReadBody(response.body, processBody, processBodyError) + } + return Promise.resolve() + } +} + +// https://fetch.spec.whatwg.org/#http-fetch +async function httpFetch (fetchParams) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. Let actualResponse be null. + let actualResponse = null + + // 4. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 5. If request’s service-workers mode is "all", then: + if (request.serviceWorkers === 'all') { + // TODO + } + + // 6. If response is null, then: + if (response === null) { + // 1. If makeCORSPreflight is true and one of these conditions is true: + // TODO + + // 2. If request’s redirect mode is "follow", then set request’s + // service-workers mode to "none". + if (request.redirect === 'follow') { + request.serviceWorkers = 'none' + } + + // 3. Set response and actualResponse to the result of running + // HTTP-network-or-cache fetch given fetchParams. + actualResponse = response = await httpNetworkOrCacheFetch(fetchParams) + + // 4. If request’s response tainting is "cors" and a CORS check + // for request and response returns failure, then return a network error. + if ( + request.responseTainting === 'cors' && + corsCheck(request, response) === 'failure' + ) { + return makeNetworkError('cors failure') + } + + // 5. If the TAO check for request and response returns failure, then set + // request’s timing allow failed flag. + if (TAOCheck(request, response) === 'failure') { + request.timingAllowFailed = true + } + } + + // 7. If either request’s response tainting or response’s type + // is "opaque", and the cross-origin resource policy check with + // request’s origin, request’s client, request’s destination, + // and actualResponse returns blocked, then return a network error. + if ( + (request.responseTainting === 'opaque' || response.type === 'opaque') && + crossOriginResourcePolicyCheck( + request.origin, + request.client, + request.destination, + actualResponse + ) === 'blocked' + ) { + return makeNetworkError('blocked') + } + + // 8. If actualResponse’s status is a redirect status, then: + if (redirectStatusSet.has(actualResponse.status)) { + // 1. If actualResponse’s status is not 303, request’s body is not null, + // and the connection uses HTTP/2, then user agents may, and are even + // encouraged to, transmit an RST_STREAM frame. + // See, https://github.com/whatwg/fetch/issues/1288 + if (request.redirect !== 'manual') { + fetchParams.controller.connection.destroy() + } + + // 2. Switch on request’s redirect mode: + if (request.redirect === 'error') { + // Set response to a network error. + response = makeNetworkError('unexpected redirect') + } else if (request.redirect === 'manual') { + // Set response to an opaque-redirect filtered response whose internal + // response is actualResponse. + // NOTE(spec): On the web this would return an `opaqueredirect` response, + // but that doesn't make sense server side. + // See https://github.com/nodejs/undici/issues/1193. + response = actualResponse + } else if (request.redirect === 'follow') { + // Set response to the result of running HTTP-redirect fetch given + // fetchParams and response. + response = await httpRedirectFetch(fetchParams, response) + } else { + assert(false) + } + } + + // 9. Set response’s timing info to timingInfo. + response.timingInfo = timingInfo + + // 10. Return response. + return response +} + +// https://fetch.spec.whatwg.org/#http-redirect-fetch +function httpRedirectFetch (fetchParams, response) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let actualResponse be response, if response is not a filtered response, + // and response’s internal response otherwise. + const actualResponse = response.internalResponse + ? response.internalResponse + : response + + // 3. Let locationURL be actualResponse’s location URL given request’s current + // URL’s fragment. + let locationURL + + try { + locationURL = responseLocationURL( + actualResponse, + requestCurrentURL(request).hash + ) + + // 4. If locationURL is null, then return response. + if (locationURL == null) { + return response + } + } catch (err) { + // 5. If locationURL is failure, then return a network error. + return Promise.resolve(makeNetworkError(err)) + } + + // 6. If locationURL’s scheme is not an HTTP(S) scheme, then return a network + // error. + if (!urlIsHttpHttpsScheme(locationURL)) { + return Promise.resolve(makeNetworkError('URL scheme must be a HTTP(S) scheme')) + } + + // 7. If request’s redirect count is 20, then return a network error. + if (request.redirectCount === 20) { + return Promise.resolve(makeNetworkError('redirect count exceeded')) + } + + // 8. Increase request’s redirect count by 1. + request.redirectCount += 1 + + // 9. If request’s mode is "cors", locationURL includes credentials, and + // request’s origin is not same origin with locationURL’s origin, then return + // a network error. + if ( + request.mode === 'cors' && + (locationURL.username || locationURL.password) && + !sameOrigin(request, locationURL) + ) { + return Promise.resolve(makeNetworkError('cross origin not allowed for request mode "cors"')) + } + + // 10. If request’s response tainting is "cors" and locationURL includes + // credentials, then return a network error. + if ( + request.responseTainting === 'cors' && + (locationURL.username || locationURL.password) + ) { + return Promise.resolve(makeNetworkError( + 'URL cannot contain credentials for request mode "cors"' + )) + } + + // 11. If actualResponse’s status is not 303, request’s body is non-null, + // and request’s body’s source is null, then return a network error. + if ( + actualResponse.status !== 303 && + request.body != null && + request.body.source == null + ) { + return Promise.resolve(makeNetworkError()) + } + + // 12. If one of the following is true + // - actualResponse’s status is 301 or 302 and request’s method is `POST` + // - actualResponse’s status is 303 and request’s method is not `GET` or `HEAD` + if ( + ([301, 302].includes(actualResponse.status) && request.method === 'POST') || + (actualResponse.status === 303 && + !GET_OR_HEAD.includes(request.method)) + ) { + // then: + // 1. Set request’s method to `GET` and request’s body to null. + request.method = 'GET' + request.body = null + + // 2. For each headerName of request-body-header name, delete headerName from + // request’s header list. + for (const headerName of requestBodyHeader) { + request.headersList.delete(headerName) + } + } + + // 13. If request’s current URL’s origin is not same origin with locationURL’s + // origin, then for each headerName of CORS non-wildcard request-header name, + // delete headerName from request’s header list. + if (!sameOrigin(requestCurrentURL(request), locationURL)) { + // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name + request.headersList.delete('authorization') + + // https://fetch.spec.whatwg.org/#authentication-entries + request.headersList.delete('proxy-authorization', true) + + // "Cookie" and "Host" are forbidden request-headers, which undici doesn't implement. + request.headersList.delete('cookie') + request.headersList.delete('host') + } + + // 14. If request’s body is non-null, then set request’s body to the first return + // value of safely extracting request’s body’s source. + if (request.body != null) { + assert(request.body.source != null) + request.body = safelyExtractBody(request.body.source)[0] + } + + // 15. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 16. Set timingInfo’s redirect end time and post-redirect start time to the + // coarsened shared current time given fetchParams’s cross-origin isolated + // capability. + timingInfo.redirectEndTime = timingInfo.postRedirectStartTime = + coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability) + + // 17. If timingInfo’s redirect start time is 0, then set timingInfo’s + // redirect start time to timingInfo’s start time. + if (timingInfo.redirectStartTime === 0) { + timingInfo.redirectStartTime = timingInfo.startTime + } + + // 18. Append locationURL to request’s URL list. + request.urlList.push(locationURL) + + // 19. Invoke set request’s referrer policy on redirect on request and + // actualResponse. + setRequestReferrerPolicyOnRedirect(request, actualResponse) + + // 20. Return the result of running main fetch given fetchParams and true. + return mainFetch(fetchParams, true) +} + +// https://fetch.spec.whatwg.org/#http-network-or-cache-fetch +async function httpNetworkOrCacheFetch ( + fetchParams, + isAuthenticationFetch = false, + isNewConnectionFetch = false +) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let httpFetchParams be null. + let httpFetchParams = null + + // 3. Let httpRequest be null. + let httpRequest = null + + // 4. Let response be null. + let response = null + + // 5. Let storedResponse be null. + // TODO: cache + + // 6. Let httpCache be null. + const httpCache = null + + // 7. Let the revalidatingFlag be unset. + const revalidatingFlag = false + + // 8. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. If request’s window is "no-window" and request’s redirect mode is + // "error", then set httpFetchParams to fetchParams and httpRequest to + // request. + if (request.window === 'no-window' && request.redirect === 'error') { + httpFetchParams = fetchParams + httpRequest = request + } else { + // Otherwise: + + // 1. Set httpRequest to a clone of request. + httpRequest = makeRequest(request) + + // 2. Set httpFetchParams to a copy of fetchParams. + httpFetchParams = { ...fetchParams } + + // 3. Set httpFetchParams’s request to httpRequest. + httpFetchParams.request = httpRequest + } + + // 3. Let includeCredentials be true if one of + const includeCredentials = + request.credentials === 'include' || + (request.credentials === 'same-origin' && + request.responseTainting === 'basic') + + // 4. Let contentLength be httpRequest’s body’s length, if httpRequest’s + // body is non-null; otherwise null. + const contentLength = httpRequest.body ? httpRequest.body.length : null + + // 5. Let contentLengthHeaderValue be null. + let contentLengthHeaderValue = null + + // 6. If httpRequest’s body is null and httpRequest’s method is `POST` or + // `PUT`, then set contentLengthHeaderValue to `0`. + if ( + httpRequest.body == null && + ['POST', 'PUT'].includes(httpRequest.method) + ) { + contentLengthHeaderValue = '0' + } + + // 7. If contentLength is non-null, then set contentLengthHeaderValue to + // contentLength, serialized and isomorphic encoded. + if (contentLength != null) { + contentLengthHeaderValue = isomorphicEncode(`${contentLength}`) + } + + // 8. If contentLengthHeaderValue is non-null, then append + // `Content-Length`/contentLengthHeaderValue to httpRequest’s header + // list. + if (contentLengthHeaderValue != null) { + httpRequest.headersList.append('content-length', contentLengthHeaderValue) + } + + // 9. If contentLengthHeaderValue is non-null, then append (`Content-Length`, + // contentLengthHeaderValue) to httpRequest’s header list. + + // 10. If contentLength is non-null and httpRequest’s keepalive is true, + // then: + if (contentLength != null && httpRequest.keepalive) { + // NOTE: keepalive is a noop outside of browser context. + } + + // 11. If httpRequest’s referrer is a URL, then append + // `Referer`/httpRequest’s referrer, serialized and isomorphic encoded, + // to httpRequest’s header list. + if (httpRequest.referrer instanceof URL) { + httpRequest.headersList.append('referer', isomorphicEncode(httpRequest.referrer.href)) + } + + // 12. Append a request `Origin` header for httpRequest. + appendRequestOriginHeader(httpRequest) + + // 13. Append the Fetch metadata headers for httpRequest. [FETCH-METADATA] + appendFetchMetadata(httpRequest) + + // 14. If httpRequest’s header list does not contain `User-Agent`, then + // user agents should append `User-Agent`/default `User-Agent` value to + // httpRequest’s header list. + if (!httpRequest.headersList.contains('user-agent')) { + httpRequest.headersList.append('user-agent', typeof esbuildDetection === 'undefined' ? 'undici' : 'node') + } + + // 15. If httpRequest’s cache mode is "default" and httpRequest’s header + // list contains `If-Modified-Since`, `If-None-Match`, + // `If-Unmodified-Since`, `If-Match`, or `If-Range`, then set + // httpRequest’s cache mode to "no-store". + if ( + httpRequest.cache === 'default' && + (httpRequest.headersList.contains('if-modified-since') || + httpRequest.headersList.contains('if-none-match') || + httpRequest.headersList.contains('if-unmodified-since') || + httpRequest.headersList.contains('if-match') || + httpRequest.headersList.contains('if-range')) + ) { + httpRequest.cache = 'no-store' + } + + // 16. If httpRequest’s cache mode is "no-cache", httpRequest’s prevent + // no-cache cache-control header modification flag is unset, and + // httpRequest’s header list does not contain `Cache-Control`, then append + // `Cache-Control`/`max-age=0` to httpRequest’s header list. + if ( + httpRequest.cache === 'no-cache' && + !httpRequest.preventNoCacheCacheControlHeaderModification && + !httpRequest.headersList.contains('cache-control') + ) { + httpRequest.headersList.append('cache-control', 'max-age=0') + } + + // 17. If httpRequest’s cache mode is "no-store" or "reload", then: + if (httpRequest.cache === 'no-store' || httpRequest.cache === 'reload') { + // 1. If httpRequest’s header list does not contain `Pragma`, then append + // `Pragma`/`no-cache` to httpRequest’s header list. + if (!httpRequest.headersList.contains('pragma')) { + httpRequest.headersList.append('pragma', 'no-cache') + } + + // 2. If httpRequest’s header list does not contain `Cache-Control`, + // then append `Cache-Control`/`no-cache` to httpRequest’s header list. + if (!httpRequest.headersList.contains('cache-control')) { + httpRequest.headersList.append('cache-control', 'no-cache') + } + } + + // 18. If httpRequest’s header list contains `Range`, then append + // `Accept-Encoding`/`identity` to httpRequest’s header list. + if (httpRequest.headersList.contains('range')) { + httpRequest.headersList.append('accept-encoding', 'identity') + } + + // 19. Modify httpRequest’s header list per HTTP. Do not append a given + // header if httpRequest’s header list contains that header’s name. + // TODO: https://github.com/whatwg/fetch/issues/1285#issuecomment-896560129 + if (!httpRequest.headersList.contains('accept-encoding')) { + if (urlHasHttpsScheme(requestCurrentURL(httpRequest))) { + httpRequest.headersList.append('accept-encoding', 'br, gzip, deflate') + } else { + httpRequest.headersList.append('accept-encoding', 'gzip, deflate') + } + } + + httpRequest.headersList.delete('host') + + // 20. If includeCredentials is true, then: + if (includeCredentials) { + // 1. If the user agent is not configured to block cookies for httpRequest + // (see section 7 of [COOKIES]), then: + // TODO: credentials + // 2. If httpRequest’s header list does not contain `Authorization`, then: + // TODO: credentials + } + + // 21. If there’s a proxy-authentication entry, use it as appropriate. + // TODO: proxy-authentication + + // 22. Set httpCache to the result of determining the HTTP cache + // partition, given httpRequest. + // TODO: cache + + // 23. If httpCache is null, then set httpRequest’s cache mode to + // "no-store". + if (httpCache == null) { + httpRequest.cache = 'no-store' + } + + // 24. If httpRequest’s cache mode is neither "no-store" nor "reload", + // then: + if (httpRequest.mode !== 'no-store' && httpRequest.mode !== 'reload') { + // TODO: cache + } + + // 9. If aborted, then return the appropriate network error for fetchParams. + // TODO + + // 10. If response is null, then: + if (response == null) { + // 1. If httpRequest’s cache mode is "only-if-cached", then return a + // network error. + if (httpRequest.mode === 'only-if-cached') { + return makeNetworkError('only if cached') + } + + // 2. Let forwardResponse be the result of running HTTP-network fetch + // given httpFetchParams, includeCredentials, and isNewConnectionFetch. + const forwardResponse = await httpNetworkFetch( + httpFetchParams, + includeCredentials, + isNewConnectionFetch + ) + + // 3. If httpRequest’s method is unsafe and forwardResponse’s status is + // in the range 200 to 399, inclusive, invalidate appropriate stored + // responses in httpCache, as per the "Invalidation" chapter of HTTP + // Caching, and set storedResponse to null. [HTTP-CACHING] + if ( + !safeMethodsSet.has(httpRequest.method) && + forwardResponse.status >= 200 && + forwardResponse.status <= 399 + ) { + // TODO: cache + } + + // 4. If the revalidatingFlag is set and forwardResponse’s status is 304, + // then: + if (revalidatingFlag && forwardResponse.status === 304) { + // TODO: cache + } + + // 5. If response is null, then: + if (response == null) { + // 1. Set response to forwardResponse. + response = forwardResponse + + // 2. Store httpRequest and forwardResponse in httpCache, as per the + // "Storing Responses in Caches" chapter of HTTP Caching. [HTTP-CACHING] + // TODO: cache + } + } + + // 11. Set response’s URL list to a clone of httpRequest’s URL list. + response.urlList = [...httpRequest.urlList] + + // 12. If httpRequest’s header list contains `Range`, then set response’s + // range-requested flag. + if (httpRequest.headersList.contains('range')) { + response.rangeRequested = true + } + + // 13. Set response’s request-includes-credentials to includeCredentials. + response.requestIncludesCredentials = includeCredentials + + // 14. If response’s status is 401, httpRequest’s response tainting is not + // "cors", includeCredentials is true, and request’s window is an environment + // settings object, then: + // TODO + + // 15. If response’s status is 407, then: + if (response.status === 407) { + // 1. If request’s window is "no-window", then return a network error. + if (request.window === 'no-window') { + return makeNetworkError() + } + + // 2. ??? + + // 3. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) + } + + // 4. Prompt the end user as appropriate in request’s window and store + // the result as a proxy-authentication entry. [HTTP-AUTH] + // TODO: Invoke some kind of callback? + + // 5. Set response to the result of running HTTP-network-or-cache fetch given + // fetchParams. + // TODO + return makeNetworkError('proxy authentication required') + } + + // 16. If all of the following are true + if ( + // response’s status is 421 + response.status === 421 && + // isNewConnectionFetch is false + !isNewConnectionFetch && + // request’s body is null, or request’s body is non-null and request’s body’s source is non-null + (request.body == null || request.body.source != null) + ) { + // then: + + // 1. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) + } + + // 2. Set response to the result of running HTTP-network-or-cache + // fetch given fetchParams, isAuthenticationFetch, and true. + + // TODO (spec): The spec doesn't specify this but we need to cancel + // the active response before we can start a new one. + // https://github.com/whatwg/fetch/issues/1293 + fetchParams.controller.connection.destroy() + + response = await httpNetworkOrCacheFetch( + fetchParams, + isAuthenticationFetch, + true + ) + } + + // 17. If isAuthenticationFetch is true, then create an authentication entry + if (isAuthenticationFetch) { + // TODO + } + + // 18. Return response. + return response +} + +// https://fetch.spec.whatwg.org/#http-network-fetch +async function httpNetworkFetch ( + fetchParams, + includeCredentials = false, + forceNewConnection = false +) { + assert(!fetchParams.controller.connection || fetchParams.controller.connection.destroyed) + + fetchParams.controller.connection = { + abort: null, + destroyed: false, + destroy (err) { + if (!this.destroyed) { + this.destroyed = true + this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError')) + } + } + } + + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 4. Let httpCache be the result of determining the HTTP cache partition, + // given request. + // TODO: cache + const httpCache = null + + // 5. If httpCache is null, then set request’s cache mode to "no-store". + if (httpCache == null) { + request.cache = 'no-store' + } + + // 6. Let networkPartitionKey be the result of determining the network + // partition key given request. + // TODO + + // 7. Let newConnection be "yes" if forceNewConnection is true; otherwise + // "no". + const newConnection = forceNewConnection ? 'yes' : 'no' // eslint-disable-line no-unused-vars + + // 8. Switch on request’s mode: + if (request.mode === 'websocket') { + // Let connection be the result of obtaining a WebSocket connection, + // given request’s current URL. + // TODO + } else { + // Let connection be the result of obtaining a connection, given + // networkPartitionKey, request’s current URL’s origin, + // includeCredentials, and forceNewConnection. + // TODO + } + + // 9. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. If connection is failure, then return a network error. + + // 2. Set timingInfo’s final connection timing info to the result of + // calling clamp and coarsen connection timing info with connection’s + // timing info, timingInfo’s post-redirect start time, and fetchParams’s + // cross-origin isolated capability. + + // 3. If connection is not an HTTP/2 connection, request’s body is non-null, + // and request’s body’s source is null, then append (`Transfer-Encoding`, + // `chunked`) to request’s header list. + + // 4. Set timingInfo’s final network-request start time to the coarsened + // shared current time given fetchParams’s cross-origin isolated + // capability. + + // 5. Set response to the result of making an HTTP request over connection + // using request with the following caveats: + + // - Follow the relevant requirements from HTTP. [HTTP] [HTTP-SEMANTICS] + // [HTTP-COND] [HTTP-CACHING] [HTTP-AUTH] + + // - If request’s body is non-null, and request’s body’s source is null, + // then the user agent may have a buffer of up to 64 kibibytes and store + // a part of request’s body in that buffer. If the user agent reads from + // request’s body beyond that buffer’s size and the user agent needs to + // resend request, then instead return a network error. + + // - Set timingInfo’s final network-response start time to the coarsened + // shared current time given fetchParams’s cross-origin isolated capability, + // immediately after the user agent’s HTTP parser receives the first byte + // of the response (e.g., frame header bytes for HTTP/2 or response status + // line for HTTP/1.x). + + // - Wait until all the headers are transmitted. + + // - Any responses whose status is in the range 100 to 199, inclusive, + // and is not 101, are to be ignored, except for the purposes of setting + // timingInfo’s final network-response start time above. + + // - If request’s header list contains `Transfer-Encoding`/`chunked` and + // response is transferred via HTTP/1.0 or older, then return a network + // error. + + // - If the HTTP request results in a TLS client certificate dialog, then: + + // 1. If request’s window is an environment settings object, make the + // dialog available in request’s window. + + // 2. Otherwise, return a network error. + + // To transmit request’s body body, run these steps: + let requestBody = null + // 1. If body is null and fetchParams’s process request end-of-body is + // non-null, then queue a fetch task given fetchParams’s process request + // end-of-body and fetchParams’s task destination. + if (request.body == null && fetchParams.processRequestEndOfBody) { + queueMicrotask(() => fetchParams.processRequestEndOfBody()) + } else if (request.body != null) { + // 2. Otherwise, if body is non-null: + + // 1. Let processBodyChunk given bytes be these steps: + const processBodyChunk = async function * (bytes) { + // 1. If the ongoing fetch is terminated, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. Run this step in parallel: transmit bytes. + yield bytes + + // 3. If fetchParams’s process request body is non-null, then run + // fetchParams’s process request body given bytes’s length. + fetchParams.processRequestBodyChunkLength?.(bytes.byteLength) + } + + // 2. Let processEndOfBody be these steps: + const processEndOfBody = () => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. If fetchParams’s process request end-of-body is non-null, + // then run fetchParams’s process request end-of-body. + if (fetchParams.processRequestEndOfBody) { + fetchParams.processRequestEndOfBody() + } + } + + // 3. Let processBodyError given e be these steps: + const processBodyError = (e) => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. If e is an "AbortError" DOMException, then abort fetchParams’s controller. + if (e.name === 'AbortError') { + fetchParams.controller.abort() + } else { + fetchParams.controller.terminate(e) + } + } + + // 4. Incrementally read request’s body given processBodyChunk, processEndOfBody, + // processBodyError, and fetchParams’s task destination. + requestBody = (async function * () { + try { + for await (const bytes of request.body.stream) { + yield * processBodyChunk(bytes) + } + processEndOfBody() + } catch (err) { + processBodyError(err) + } + })() + } + + try { + // socket is only provided for websockets + const { body, status, statusText, headersList, socket } = await dispatch({ body: requestBody }) + + if (socket) { + response = makeResponse({ status, statusText, headersList, socket }) + } else { + const iterator = body[Symbol.asyncIterator]() + fetchParams.controller.next = () => iterator.next() + + response = makeResponse({ status, statusText, headersList }) + } + } catch (err) { + // 10. If aborted, then: + if (err.name === 'AbortError') { + // 1. If connection uses HTTP/2, then transmit an RST_STREAM frame. + fetchParams.controller.connection.destroy() + + // 2. Return the appropriate network error for fetchParams. + return makeAppropriateNetworkError(fetchParams, err) + } + + return makeNetworkError(err) + } + + // 11. Let pullAlgorithm be an action that resumes the ongoing fetch + // if it is suspended. + const pullAlgorithm = () => { + fetchParams.controller.resume() + } + + // 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s + // controller with reason, given reason. + const cancelAlgorithm = (reason) => { + fetchParams.controller.abort(reason) + } + + // 13. Let highWaterMark be a non-negative, non-NaN number, chosen by + // the user agent. + // TODO + + // 14. Let sizeAlgorithm be an algorithm that accepts a chunk object + // and returns a non-negative, non-NaN, non-infinite number, chosen by the user agent. + // TODO + + // 15. Let stream be a new ReadableStream. + // 16. Set up stream with pullAlgorithm set to pullAlgorithm, + // cancelAlgorithm set to cancelAlgorithm, highWaterMark set to + // highWaterMark, and sizeAlgorithm set to sizeAlgorithm. + if (!ReadableStream) { + ReadableStream = require('stream/web').ReadableStream + } + + const stream = new ReadableStream( + { + async start (controller) { + fetchParams.controller.controller = controller + }, + async pull (controller) { + await pullAlgorithm(controller) + }, + async cancel (reason) { + await cancelAlgorithm(reason) + } + }, + { + highWaterMark: 0, + size () { + return 1 + } + } + ) + + // 17. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. Set response’s body to a new body whose stream is stream. + response.body = { stream } + + // 2. If response is not a network error and request’s cache mode is + // not "no-store", then update response in httpCache for request. + // TODO + + // 3. If includeCredentials is true and the user agent is not configured + // to block cookies for request (see section 7 of [COOKIES]), then run the + // "set-cookie-string" parsing algorithm (see section 5.2 of [COOKIES]) on + // the value of each header whose name is a byte-case-insensitive match for + // `Set-Cookie` in response’s header list, if any, and request’s current URL. + // TODO + + // 18. If aborted, then: + // TODO + + // 19. Run these steps in parallel: + + // 1. Run these steps, but abort when fetchParams is canceled: + fetchParams.controller.on('terminated', onAborted) + fetchParams.controller.resume = async () => { + // 1. While true + while (true) { + // 1-3. See onData... + + // 4. Set bytes to the result of handling content codings given + // codings and bytes. + let bytes + let isFailure + try { + const { done, value } = await fetchParams.controller.next() + + if (isAborted(fetchParams)) { + break + } + + bytes = done ? undefined : value + } catch (err) { + if (fetchParams.controller.ended && !timingInfo.encodedBodySize) { + // zlib doesn't like empty streams. + bytes = undefined + } else { + bytes = err + + // err may be propagated from the result of calling readablestream.cancel, + // which might not be an error. https://github.com/nodejs/undici/issues/2009 + isFailure = true + } + } + + if (bytes === undefined) { + // 2. Otherwise, if the bytes transmission for response’s message + // body is done normally and stream is readable, then close + // stream, finalize response for fetchParams and response, and + // abort these in-parallel steps. + readableStreamClose(fetchParams.controller.controller) + + finalizeResponse(fetchParams, response) + + return + } + + // 5. Increase timingInfo’s decoded body size by bytes’s length. + timingInfo.decodedBodySize += bytes?.byteLength ?? 0 + + // 6. If bytes is failure, then terminate fetchParams’s controller. + if (isFailure) { + fetchParams.controller.terminate(bytes) + return + } + + // 7. Enqueue a Uint8Array wrapping an ArrayBuffer containing bytes + // into stream. + fetchParams.controller.controller.enqueue(new Uint8Array(bytes)) + + // 8. If stream is errored, then terminate the ongoing fetch. + if (isErrored(stream)) { + fetchParams.controller.terminate() + return + } + + // 9. If stream doesn’t need more data ask the user agent to suspend + // the ongoing fetch. + if (!fetchParams.controller.controller.desiredSize) { + return + } + } + } + + // 2. If aborted, then: + function onAborted (reason) { + // 2. If fetchParams is aborted, then: + if (isAborted(fetchParams)) { + // 1. Set response’s aborted flag. + response.aborted = true + + // 2. If stream is readable, then error stream with the result of + // deserialize a serialized abort reason given fetchParams’s + // controller’s serialized abort reason and an + // implementation-defined realm. + if (isReadable(stream)) { + fetchParams.controller.controller.error( + fetchParams.controller.serializedAbortReason + ) + } + } else { + // 3. Otherwise, if stream is readable, error stream with a TypeError. + if (isReadable(stream)) { + fetchParams.controller.controller.error(new TypeError('terminated', { + cause: isErrorLike(reason) ? reason : undefined + })) + } + } + + // 4. If connection uses HTTP/2, then transmit an RST_STREAM frame. + // 5. Otherwise, the user agent should close connection unless it would be bad for performance to do so. + fetchParams.controller.connection.destroy() + } + + // 20. Return response. + return response + + async function dispatch ({ body }) { + const url = requestCurrentURL(request) + /** @type {import('../..').Agent} */ + const agent = fetchParams.controller.dispatcher + + return new Promise((resolve, reject) => agent.dispatch( + { + path: url.pathname + url.search, + origin: url.origin, + method: request.method, + body: fetchParams.controller.dispatcher.isMockActive ? request.body && (request.body.source || request.body.stream) : body, + headers: request.headersList.entries, + maxRedirections: 0, + upgrade: request.mode === 'websocket' ? 'websocket' : undefined + }, + { + body: null, + abort: null, + + onConnect (abort) { + // TODO (fix): Do we need connection here? + const { connection } = fetchParams.controller + + if (connection.destroyed) { + abort(new DOMException('The operation was aborted.', 'AbortError')) + } else { + fetchParams.controller.on('terminated', abort) + this.abort = connection.abort = abort + } + }, + + onHeaders (status, headersList, resume, statusText) { + if (status < 200) { + return + } + + let codings = [] + let location = '' + + const headers = new Headers() + + // For H2, the headers are a plain JS object + // We distinguish between them and iterate accordingly + if (Array.isArray(headersList)) { + for (let n = 0; n < headersList.length; n += 2) { + const key = headersList[n + 0].toString('latin1') + const val = headersList[n + 1].toString('latin1') + if (key.toLowerCase() === 'content-encoding') { + // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 + // "All content-coding values are case-insensitive..." + codings = val.toLowerCase().split(',').map((x) => x.trim()) + } else if (key.toLowerCase() === 'location') { + location = val + } + + headers[kHeadersList].append(key, val) + } + } else { + const keys = Object.keys(headersList) + for (const key of keys) { + const val = headersList[key] + if (key.toLowerCase() === 'content-encoding') { + // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 + // "All content-coding values are case-insensitive..." + codings = val.toLowerCase().split(',').map((x) => x.trim()).reverse() + } else if (key.toLowerCase() === 'location') { + location = val + } + + headers[kHeadersList].append(key, val) + } + } + + this.body = new Readable({ read: resume }) + + const decoders = [] + + const willFollow = request.redirect === 'follow' && + location && + redirectStatusSet.has(status) + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding + if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !willFollow) { + for (const coding of codings) { + // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2 + if (coding === 'x-gzip' || coding === 'gzip') { + decoders.push(zlib.createGunzip({ + // Be less strict when decoding compressed responses, since sometimes + // servers send slightly invalid responses that are still accepted + // by common browsers. + // Always using Z_SYNC_FLUSH is what cURL does. + flush: zlib.constants.Z_SYNC_FLUSH, + finishFlush: zlib.constants.Z_SYNC_FLUSH + })) + } else if (coding === 'deflate') { + decoders.push(zlib.createInflate()) + } else if (coding === 'br') { + decoders.push(zlib.createBrotliDecompress()) + } else { + decoders.length = 0 + break + } + } + } + + resolve({ + status, + statusText, + headersList: headers[kHeadersList], + body: decoders.length + ? pipeline(this.body, ...decoders, () => { }) + : this.body.on('error', () => {}) + }) + + return true + }, + + onData (chunk) { + if (fetchParams.controller.dump) { + return + } + + // 1. If one or more bytes have been transmitted from response’s + // message body, then: + + // 1. Let bytes be the transmitted bytes. + const bytes = chunk + + // 2. Let codings be the result of extracting header list values + // given `Content-Encoding` and response’s header list. + // See pullAlgorithm. + + // 3. Increase timingInfo’s encoded body size by bytes’s length. + timingInfo.encodedBodySize += bytes.byteLength + + // 4. See pullAlgorithm... + + return this.body.push(bytes) + }, + + onComplete () { + if (this.abort) { + fetchParams.controller.off('terminated', this.abort) + } + + fetchParams.controller.ended = true + + this.body.push(null) + }, + + onError (error) { + if (this.abort) { + fetchParams.controller.off('terminated', this.abort) + } + + this.body?.destroy(error) + + fetchParams.controller.terminate(error) + + reject(error) + }, + + onUpgrade (status, headersList, socket) { + if (status !== 101) { + return + } + + const headers = new Headers() + + for (let n = 0; n < headersList.length; n += 2) { + const key = headersList[n + 0].toString('latin1') + const val = headersList[n + 1].toString('latin1') + + headers[kHeadersList].append(key, val) + } + + resolve({ + status, + statusText: STATUS_CODES[status], + headersList: headers[kHeadersList], + socket + }) + + return true + } + } + )) + } +} + +module.exports = { + fetch, + Fetch, + fetching, + finalizeAndReportTiming +} diff --git a/lib/fetch/request.js b/lib/fetch/request.js new file mode 100644 index 0000000..6fe4dff --- /dev/null +++ b/lib/fetch/request.js @@ -0,0 +1,946 @@ +/* globals AbortController */ + +'use strict' + +const { extractBody, mixinBody, cloneBody } = require('./body') +const { Headers, fill: fillHeaders, HeadersList } = require('./headers') +const { FinalizationRegistry } = require('../compat/dispatcher-weakref')() +const util = require('../core/util') +const { + isValidHTTPToken, + sameOrigin, + normalizeMethod, + makePolicyContainer, + normalizeMethodRecord +} = require('./util') +const { + forbiddenMethodsSet, + corsSafeListedMethodsSet, + referrerPolicy, + requestRedirect, + requestMode, + requestCredentials, + requestCache, + requestDuplex +} = require('./constants') +const { kEnumerableProperty } = util +const { kHeaders, kSignal, kState, kGuard, kRealm } = require('./symbols') +const { webidl } = require('./webidl') +const { getGlobalOrigin } = require('./global') +const { URLSerializer } = require('./dataURL') +const { kHeadersList, kConstruct } = require('../core/symbols') +const assert = require('assert') +const { getMaxListeners, setMaxListeners, getEventListeners, defaultMaxListeners } = require('events') + +let TransformStream = globalThis.TransformStream + +const kAbortController = Symbol('abortController') + +const requestFinalizer = new FinalizationRegistry(({ signal, abort }) => { + signal.removeEventListener('abort', abort) +}) + +// https://fetch.spec.whatwg.org/#request-class +class Request { + // https://fetch.spec.whatwg.org/#dom-request + constructor (input, init = {}) { + if (input === kConstruct) { + return + } + + webidl.argumentLengthCheck(arguments, 1, { header: 'Request constructor' }) + + input = webidl.converters.RequestInfo(input) + init = webidl.converters.RequestInit(init) + + // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object + this[kRealm] = { + settingsObject: { + baseUrl: getGlobalOrigin(), + get origin () { + return this.baseUrl?.origin + }, + policyContainer: makePolicyContainer() + } + } + + // 1. Let request be null. + let request = null + + // 2. Let fallbackMode be null. + let fallbackMode = null + + // 3. Let baseURL be this’s relevant settings object’s API base URL. + const baseUrl = this[kRealm].settingsObject.baseUrl + + // 4. Let signal be null. + let signal = null + + // 5. If input is a string, then: + if (typeof input === 'string') { + // 1. Let parsedURL be the result of parsing input with baseURL. + // 2. If parsedURL is failure, then throw a TypeError. + let parsedURL + try { + parsedURL = new URL(input, baseUrl) + } catch (err) { + throw new TypeError('Failed to parse URL from ' + input, { cause: err }) + } + + // 3. If parsedURL includes credentials, then throw a TypeError. + if (parsedURL.username || parsedURL.password) { + throw new TypeError( + 'Request cannot be constructed from a URL that includes credentials: ' + + input + ) + } + + // 4. Set request to a new request whose URL is parsedURL. + request = makeRequest({ urlList: [parsedURL] }) + + // 5. Set fallbackMode to "cors". + fallbackMode = 'cors' + } else { + // 6. Otherwise: + + // 7. Assert: input is a Request object. + assert(input instanceof Request) + + // 8. Set request to input’s request. + request = input[kState] + + // 9. Set signal to input’s signal. + signal = input[kSignal] + } + + // 7. Let origin be this’s relevant settings object’s origin. + const origin = this[kRealm].settingsObject.origin + + // 8. Let window be "client". + let window = 'client' + + // 9. If request’s window is an environment settings object and its origin + // is same origin with origin, then set window to request’s window. + if ( + request.window?.constructor?.name === 'EnvironmentSettingsObject' && + sameOrigin(request.window, origin) + ) { + window = request.window + } + + // 10. If init["window"] exists and is non-null, then throw a TypeError. + if (init.window != null) { + throw new TypeError(`'window' option '${window}' must be null`) + } + + // 11. If init["window"] exists, then set window to "no-window". + if ('window' in init) { + window = 'no-window' + } + + // 12. Set request to a new request with the following properties: + request = makeRequest({ + // URL request’s URL. + // undici implementation note: this is set as the first item in request's urlList in makeRequest + // method request’s method. + method: request.method, + // header list A copy of request’s header list. + // undici implementation note: headersList is cloned in makeRequest + headersList: request.headersList, + // unsafe-request flag Set. + unsafeRequest: request.unsafeRequest, + // client This’s relevant settings object. + client: this[kRealm].settingsObject, + // window window. + window, + // priority request’s priority. + priority: request.priority, + // origin request’s origin. The propagation of the origin is only significant for navigation requests + // being handled by a service worker. In this scenario a request can have an origin that is different + // from the current client. + origin: request.origin, + // referrer request’s referrer. + referrer: request.referrer, + // referrer policy request’s referrer policy. + referrerPolicy: request.referrerPolicy, + // mode request’s mode. + mode: request.mode, + // credentials mode request’s credentials mode. + credentials: request.credentials, + // cache mode request’s cache mode. + cache: request.cache, + // redirect mode request’s redirect mode. + redirect: request.redirect, + // integrity metadata request’s integrity metadata. + integrity: request.integrity, + // keepalive request’s keepalive. + keepalive: request.keepalive, + // reload-navigation flag request’s reload-navigation flag. + reloadNavigation: request.reloadNavigation, + // history-navigation flag request’s history-navigation flag. + historyNavigation: request.historyNavigation, + // URL list A clone of request’s URL list. + urlList: [...request.urlList] + }) + + const initHasKey = Object.keys(init).length !== 0 + + // 13. If init is not empty, then: + if (initHasKey) { + // 1. If request’s mode is "navigate", then set it to "same-origin". + if (request.mode === 'navigate') { + request.mode = 'same-origin' + } + + // 2. Unset request’s reload-navigation flag. + request.reloadNavigation = false + + // 3. Unset request’s history-navigation flag. + request.historyNavigation = false + + // 4. Set request’s origin to "client". + request.origin = 'client' + + // 5. Set request’s referrer to "client" + request.referrer = 'client' + + // 6. Set request’s referrer policy to the empty string. + request.referrerPolicy = '' + + // 7. Set request’s URL to request’s current URL. + request.url = request.urlList[request.urlList.length - 1] + + // 8. Set request’s URL list to « request’s URL ». + request.urlList = [request.url] + } + + // 14. If init["referrer"] exists, then: + if (init.referrer !== undefined) { + // 1. Let referrer be init["referrer"]. + const referrer = init.referrer + + // 2. If referrer is the empty string, then set request’s referrer to "no-referrer". + if (referrer === '') { + request.referrer = 'no-referrer' + } else { + // 1. Let parsedReferrer be the result of parsing referrer with + // baseURL. + // 2. If parsedReferrer is failure, then throw a TypeError. + let parsedReferrer + try { + parsedReferrer = new URL(referrer, baseUrl) + } catch (err) { + throw new TypeError(`Referrer "${referrer}" is not a valid URL.`, { cause: err }) + } + + // 3. If one of the following is true + // - parsedReferrer’s scheme is "about" and path is the string "client" + // - parsedReferrer’s origin is not same origin with origin + // then set request’s referrer to "client". + if ( + (parsedReferrer.protocol === 'about:' && parsedReferrer.hostname === 'client') || + (origin && !sameOrigin(parsedReferrer, this[kRealm].settingsObject.baseUrl)) + ) { + request.referrer = 'client' + } else { + // 4. Otherwise, set request’s referrer to parsedReferrer. + request.referrer = parsedReferrer + } + } + } + + // 15. If init["referrerPolicy"] exists, then set request’s referrer policy + // to it. + if (init.referrerPolicy !== undefined) { + request.referrerPolicy = init.referrerPolicy + } + + // 16. Let mode be init["mode"] if it exists, and fallbackMode otherwise. + let mode + if (init.mode !== undefined) { + mode = init.mode + } else { + mode = fallbackMode + } + + // 17. If mode is "navigate", then throw a TypeError. + if (mode === 'navigate') { + throw webidl.errors.exception({ + header: 'Request constructor', + message: 'invalid request mode navigate.' + }) + } + + // 18. If mode is non-null, set request’s mode to mode. + if (mode != null) { + request.mode = mode + } + + // 19. If init["credentials"] exists, then set request’s credentials mode + // to it. + if (init.credentials !== undefined) { + request.credentials = init.credentials + } + + // 18. If init["cache"] exists, then set request’s cache mode to it. + if (init.cache !== undefined) { + request.cache = init.cache + } + + // 21. If request’s cache mode is "only-if-cached" and request’s mode is + // not "same-origin", then throw a TypeError. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + throw new TypeError( + "'only-if-cached' can be set only with 'same-origin' mode" + ) + } + + // 22. If init["redirect"] exists, then set request’s redirect mode to it. + if (init.redirect !== undefined) { + request.redirect = init.redirect + } + + // 23. If init["integrity"] exists, then set request’s integrity metadata to it. + if (init.integrity != null) { + request.integrity = String(init.integrity) + } + + // 24. If init["keepalive"] exists, then set request’s keepalive to it. + if (init.keepalive !== undefined) { + request.keepalive = Boolean(init.keepalive) + } + + // 25. If init["method"] exists, then: + if (init.method !== undefined) { + // 1. Let method be init["method"]. + let method = init.method + + // 2. If method is not a method or method is a forbidden method, then + // throw a TypeError. + if (!isValidHTTPToken(method)) { + throw new TypeError(`'${method}' is not a valid HTTP method.`) + } + + if (forbiddenMethodsSet.has(method.toUpperCase())) { + throw new TypeError(`'${method}' HTTP method is unsupported.`) + } + + // 3. Normalize method. + method = normalizeMethodRecord[method] ?? normalizeMethod(method) + + // 4. Set request’s method to method. + request.method = method + } + + // 26. If init["signal"] exists, then set signal to it. + if (init.signal !== undefined) { + signal = init.signal + } + + // 27. Set this’s request to request. + this[kState] = request + + // 28. Set this’s signal to a new AbortSignal object with this’s relevant + // Realm. + // TODO: could this be simplified with AbortSignal.any + // (https://dom.spec.whatwg.org/#dom-abortsignal-any) + const ac = new AbortController() + this[kSignal] = ac.signal + this[kSignal][kRealm] = this[kRealm] + + // 29. If signal is not null, then make this’s signal follow signal. + if (signal != null) { + if ( + !signal || + typeof signal.aborted !== 'boolean' || + typeof signal.addEventListener !== 'function' + ) { + throw new TypeError( + "Failed to construct 'Request': member signal is not of type AbortSignal." + ) + } + + if (signal.aborted) { + ac.abort(signal.reason) + } else { + // Keep a strong ref to ac while request object + // is alive. This is needed to prevent AbortController + // from being prematurely garbage collected. + // See, https://github.com/nodejs/undici/issues/1926. + this[kAbortController] = ac + + const acRef = new WeakRef(ac) + const abort = function () { + const ac = acRef.deref() + if (ac !== undefined) { + ac.abort(this.reason) + } + } + + // Third-party AbortControllers may not work with these. + // See, https://github.com/nodejs/undici/pull/1910#issuecomment-1464495619. + try { + // If the max amount of listeners is equal to the default, increase it + // This is only available in node >= v19.9.0 + if (typeof getMaxListeners === 'function' && getMaxListeners(signal) === defaultMaxListeners) { + setMaxListeners(100, signal) + } else if (getEventListeners(signal, 'abort').length >= defaultMaxListeners) { + setMaxListeners(100, signal) + } + } catch {} + + util.addAbortListener(signal, abort) + requestFinalizer.register(ac, { signal, abort }) + } + } + + // 30. Set this’s headers to a new Headers object with this’s relevant + // Realm, whose header list is request’s header list and guard is + // "request". + this[kHeaders] = new Headers(kConstruct) + this[kHeaders][kHeadersList] = request.headersList + this[kHeaders][kGuard] = 'request' + this[kHeaders][kRealm] = this[kRealm] + + // 31. If this’s request’s mode is "no-cors", then: + if (mode === 'no-cors') { + // 1. If this’s request’s method is not a CORS-safelisted method, + // then throw a TypeError. + if (!corsSafeListedMethodsSet.has(request.method)) { + throw new TypeError( + `'${request.method} is unsupported in no-cors mode.` + ) + } + + // 2. Set this’s headers’s guard to "request-no-cors". + this[kHeaders][kGuard] = 'request-no-cors' + } + + // 32. If init is not empty, then: + if (initHasKey) { + /** @type {HeadersList} */ + const headersList = this[kHeaders][kHeadersList] + // 1. Let headers be a copy of this’s headers and its associated header + // list. + // 2. If init["headers"] exists, then set headers to init["headers"]. + const headers = init.headers !== undefined ? init.headers : new HeadersList(headersList) + + // 3. Empty this’s headers’s header list. + headersList.clear() + + // 4. If headers is a Headers object, then for each header in its header + // list, append header’s name/header’s value to this’s headers. + if (headers instanceof HeadersList) { + for (const [key, val] of headers) { + headersList.append(key, val) + } + // Note: Copy the `set-cookie` meta-data. + headersList.cookies = headers.cookies + } else { + // 5. Otherwise, fill this’s headers with headers. + fillHeaders(this[kHeaders], headers) + } + } + + // 33. Let inputBody be input’s request’s body if input is a Request + // object; otherwise null. + const inputBody = input instanceof Request ? input[kState].body : null + + // 34. If either init["body"] exists and is non-null or inputBody is + // non-null, and request’s method is `GET` or `HEAD`, then throw a + // TypeError. + if ( + (init.body != null || inputBody != null) && + (request.method === 'GET' || request.method === 'HEAD') + ) { + throw new TypeError('Request with GET/HEAD method cannot have body.') + } + + // 35. Let initBody be null. + let initBody = null + + // 36. If init["body"] exists and is non-null, then: + if (init.body != null) { + // 1. Let Content-Type be null. + // 2. Set initBody and Content-Type to the result of extracting + // init["body"], with keepalive set to request’s keepalive. + const [extractedBody, contentType] = extractBody( + init.body, + request.keepalive + ) + initBody = extractedBody + + // 3, If Content-Type is non-null and this’s headers’s header list does + // not contain `Content-Type`, then append `Content-Type`/Content-Type to + // this’s headers. + if (contentType && !this[kHeaders][kHeadersList].contains('content-type')) { + this[kHeaders].append('content-type', contentType) + } + } + + // 37. Let inputOrInitBody be initBody if it is non-null; otherwise + // inputBody. + const inputOrInitBody = initBody ?? inputBody + + // 38. If inputOrInitBody is non-null and inputOrInitBody’s source is + // null, then: + if (inputOrInitBody != null && inputOrInitBody.source == null) { + // 1. If initBody is non-null and init["duplex"] does not exist, + // then throw a TypeError. + if (initBody != null && init.duplex == null) { + throw new TypeError('RequestInit: duplex option is required when sending a body.') + } + + // 2. If this’s request’s mode is neither "same-origin" nor "cors", + // then throw a TypeError. + if (request.mode !== 'same-origin' && request.mode !== 'cors') { + throw new TypeError( + 'If request is made from ReadableStream, mode should be "same-origin" or "cors"' + ) + } + + // 3. Set this’s request’s use-CORS-preflight flag. + request.useCORSPreflightFlag = true + } + + // 39. Let finalBody be inputOrInitBody. + let finalBody = inputOrInitBody + + // 40. If initBody is null and inputBody is non-null, then: + if (initBody == null && inputBody != null) { + // 1. If input is unusable, then throw a TypeError. + if (util.isDisturbed(inputBody.stream) || inputBody.stream.locked) { + throw new TypeError( + 'Cannot construct a Request with a Request object that has already been used.' + ) + } + + // 2. Set finalBody to the result of creating a proxy for inputBody. + if (!TransformStream) { + TransformStream = require('stream/web').TransformStream + } + + // https://streams.spec.whatwg.org/#readablestream-create-a-proxy + const identityTransform = new TransformStream() + inputBody.stream.pipeThrough(identityTransform) + finalBody = { + source: inputBody.source, + length: inputBody.length, + stream: identityTransform.readable + } + } + + // 41. Set this’s request’s body to finalBody. + this[kState].body = finalBody + } + + // Returns request’s HTTP method, which is "GET" by default. + get method () { + webidl.brandCheck(this, Request) + + // The method getter steps are to return this’s request’s method. + return this[kState].method + } + + // Returns the URL of request as a string. + get url () { + webidl.brandCheck(this, Request) + + // The url getter steps are to return this’s request’s URL, serialized. + return URLSerializer(this[kState].url) + } + + // Returns a Headers object consisting of the headers associated with request. + // Note that headers added in the network layer by the user agent will not + // be accounted for in this object, e.g., the "Host" header. + get headers () { + webidl.brandCheck(this, Request) + + // The headers getter steps are to return this’s headers. + return this[kHeaders] + } + + // Returns the kind of resource requested by request, e.g., "document" + // or "script". + get destination () { + webidl.brandCheck(this, Request) + + // The destination getter are to return this’s request’s destination. + return this[kState].destination + } + + // Returns the referrer of request. Its value can be a same-origin URL if + // explicitly set in init, the empty string to indicate no referrer, and + // "about:client" when defaulting to the global’s default. This is used + // during fetching to determine the value of the `Referer` header of the + // request being made. + get referrer () { + webidl.brandCheck(this, Request) + + // 1. If this’s request’s referrer is "no-referrer", then return the + // empty string. + if (this[kState].referrer === 'no-referrer') { + return '' + } + + // 2. If this’s request’s referrer is "client", then return + // "about:client". + if (this[kState].referrer === 'client') { + return 'about:client' + } + + // Return this’s request’s referrer, serialized. + return this[kState].referrer.toString() + } + + // Returns the referrer policy associated with request. + // This is used during fetching to compute the value of the request’s + // referrer. + get referrerPolicy () { + webidl.brandCheck(this, Request) + + // The referrerPolicy getter steps are to return this’s request’s referrer policy. + return this[kState].referrerPolicy + } + + // Returns the mode associated with request, which is a string indicating + // whether the request will use CORS, or will be restricted to same-origin + // URLs. + get mode () { + webidl.brandCheck(this, Request) + + // The mode getter steps are to return this’s request’s mode. + return this[kState].mode + } + + // Returns the credentials mode associated with request, + // which is a string indicating whether credentials will be sent with the + // request always, never, or only when sent to a same-origin URL. + get credentials () { + // The credentials getter steps are to return this’s request’s credentials mode. + return this[kState].credentials + } + + // Returns the cache mode associated with request, + // which is a string indicating how the request will + // interact with the browser’s cache when fetching. + get cache () { + webidl.brandCheck(this, Request) + + // The cache getter steps are to return this’s request’s cache mode. + return this[kState].cache + } + + // Returns the redirect mode associated with request, + // which is a string indicating how redirects for the + // request will be handled during fetching. A request + // will follow redirects by default. + get redirect () { + webidl.brandCheck(this, Request) + + // The redirect getter steps are to return this’s request’s redirect mode. + return this[kState].redirect + } + + // Returns request’s subresource integrity metadata, which is a + // cryptographic hash of the resource being fetched. Its value + // consists of multiple hashes separated by whitespace. [SRI] + get integrity () { + webidl.brandCheck(this, Request) + + // The integrity getter steps are to return this’s request’s integrity + // metadata. + return this[kState].integrity + } + + // Returns a boolean indicating whether or not request can outlive the + // global in which it was created. + get keepalive () { + webidl.brandCheck(this, Request) + + // The keepalive getter steps are to return this’s request’s keepalive. + return this[kState].keepalive + } + + // Returns a boolean indicating whether or not request is for a reload + // navigation. + get isReloadNavigation () { + webidl.brandCheck(this, Request) + + // The isReloadNavigation getter steps are to return true if this’s + // request’s reload-navigation flag is set; otherwise false. + return this[kState].reloadNavigation + } + + // Returns a boolean indicating whether or not request is for a history + // navigation (a.k.a. back-foward navigation). + get isHistoryNavigation () { + webidl.brandCheck(this, Request) + + // The isHistoryNavigation getter steps are to return true if this’s request’s + // history-navigation flag is set; otherwise false. + return this[kState].historyNavigation + } + + // Returns the signal associated with request, which is an AbortSignal + // object indicating whether or not request has been aborted, and its + // abort event handler. + get signal () { + webidl.brandCheck(this, Request) + + // The signal getter steps are to return this’s signal. + return this[kSignal] + } + + get body () { + webidl.brandCheck(this, Request) + + return this[kState].body ? this[kState].body.stream : null + } + + get bodyUsed () { + webidl.brandCheck(this, Request) + + return !!this[kState].body && util.isDisturbed(this[kState].body.stream) + } + + get duplex () { + webidl.brandCheck(this, Request) + + return 'half' + } + + // Returns a clone of request. + clone () { + webidl.brandCheck(this, Request) + + // 1. If this is unusable, then throw a TypeError. + if (this.bodyUsed || this.body?.locked) { + throw new TypeError('unusable') + } + + // 2. Let clonedRequest be the result of cloning this’s request. + const clonedRequest = cloneRequest(this[kState]) + + // 3. Let clonedRequestObject be the result of creating a Request object, + // given clonedRequest, this’s headers’s guard, and this’s relevant Realm. + const clonedRequestObject = new Request(kConstruct) + clonedRequestObject[kState] = clonedRequest + clonedRequestObject[kRealm] = this[kRealm] + clonedRequestObject[kHeaders] = new Headers(kConstruct) + clonedRequestObject[kHeaders][kHeadersList] = clonedRequest.headersList + clonedRequestObject[kHeaders][kGuard] = this[kHeaders][kGuard] + clonedRequestObject[kHeaders][kRealm] = this[kHeaders][kRealm] + + // 4. Make clonedRequestObject’s signal follow this’s signal. + const ac = new AbortController() + if (this.signal.aborted) { + ac.abort(this.signal.reason) + } else { + util.addAbortListener( + this.signal, + () => { + ac.abort(this.signal.reason) + } + ) + } + clonedRequestObject[kSignal] = ac.signal + + // 4. Return clonedRequestObject. + return clonedRequestObject + } +} + +mixinBody(Request) + +function makeRequest (init) { + // https://fetch.spec.whatwg.org/#requests + const request = { + method: 'GET', + localURLsOnly: false, + unsafeRequest: false, + body: null, + client: null, + reservedClient: null, + replacesClientId: '', + window: 'client', + keepalive: false, + serviceWorkers: 'all', + initiator: '', + destination: '', + priority: null, + origin: 'client', + policyContainer: 'client', + referrer: 'client', + referrerPolicy: '', + mode: 'no-cors', + useCORSPreflightFlag: false, + credentials: 'same-origin', + useCredentials: false, + cache: 'default', + redirect: 'follow', + integrity: '', + cryptoGraphicsNonceMetadata: '', + parserMetadata: '', + reloadNavigation: false, + historyNavigation: false, + userActivation: false, + taintedOrigin: false, + redirectCount: 0, + responseTainting: 'basic', + preventNoCacheCacheControlHeaderModification: false, + done: false, + timingAllowFailed: false, + ...init, + headersList: init.headersList + ? new HeadersList(init.headersList) + : new HeadersList() + } + request.url = request.urlList[0] + return request +} + +// https://fetch.spec.whatwg.org/#concept-request-clone +function cloneRequest (request) { + // To clone a request request, run these steps: + + // 1. Let newRequest be a copy of request, except for its body. + const newRequest = makeRequest({ ...request, body: null }) + + // 2. If request’s body is non-null, set newRequest’s body to the + // result of cloning request’s body. + if (request.body != null) { + newRequest.body = cloneBody(request.body) + } + + // 3. Return newRequest. + return newRequest +} + +Object.defineProperties(Request.prototype, { + method: kEnumerableProperty, + url: kEnumerableProperty, + headers: kEnumerableProperty, + redirect: kEnumerableProperty, + clone: kEnumerableProperty, + signal: kEnumerableProperty, + duplex: kEnumerableProperty, + destination: kEnumerableProperty, + body: kEnumerableProperty, + bodyUsed: kEnumerableProperty, + isHistoryNavigation: kEnumerableProperty, + isReloadNavigation: kEnumerableProperty, + keepalive: kEnumerableProperty, + integrity: kEnumerableProperty, + cache: kEnumerableProperty, + credentials: kEnumerableProperty, + attribute: kEnumerableProperty, + referrerPolicy: kEnumerableProperty, + referrer: kEnumerableProperty, + mode: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'Request', + configurable: true + } +}) + +webidl.converters.Request = webidl.interfaceConverter( + Request +) + +// https://fetch.spec.whatwg.org/#requestinfo +webidl.converters.RequestInfo = function (V) { + if (typeof V === 'string') { + return webidl.converters.USVString(V) + } + + if (V instanceof Request) { + return webidl.converters.Request(V) + } + + return webidl.converters.USVString(V) +} + +webidl.converters.AbortSignal = webidl.interfaceConverter( + AbortSignal +) + +// https://fetch.spec.whatwg.org/#requestinit +webidl.converters.RequestInit = webidl.dictionaryConverter([ + { + key: 'method', + converter: webidl.converters.ByteString + }, + { + key: 'headers', + converter: webidl.converters.HeadersInit + }, + { + key: 'body', + converter: webidl.nullableConverter( + webidl.converters.BodyInit + ) + }, + { + key: 'referrer', + converter: webidl.converters.USVString + }, + { + key: 'referrerPolicy', + converter: webidl.converters.DOMString, + // https://w3c.github.io/webappsec-referrer-policy/#referrer-policy + allowedValues: referrerPolicy + }, + { + key: 'mode', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#concept-request-mode + allowedValues: requestMode + }, + { + key: 'credentials', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestcredentials + allowedValues: requestCredentials + }, + { + key: 'cache', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestcache + allowedValues: requestCache + }, + { + key: 'redirect', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestredirect + allowedValues: requestRedirect + }, + { + key: 'integrity', + converter: webidl.converters.DOMString + }, + { + key: 'keepalive', + converter: webidl.converters.boolean + }, + { + key: 'signal', + converter: webidl.nullableConverter( + (signal) => webidl.converters.AbortSignal( + signal, + { strict: false } + ) + ) + }, + { + key: 'window', + converter: webidl.converters.any + }, + { + key: 'duplex', + converter: webidl.converters.DOMString, + allowedValues: requestDuplex + } +]) + +module.exports = { Request, makeRequest } diff --git a/lib/fetch/response.js b/lib/fetch/response.js new file mode 100644 index 0000000..7338612 --- /dev/null +++ b/lib/fetch/response.js @@ -0,0 +1,571 @@ +'use strict' + +const { Headers, HeadersList, fill } = require('./headers') +const { extractBody, cloneBody, mixinBody } = require('./body') +const util = require('../core/util') +const { kEnumerableProperty } = util +const { + isValidReasonPhrase, + isCancelled, + isAborted, + isBlobLike, + serializeJavascriptValueToJSONString, + isErrorLike, + isomorphicEncode +} = require('./util') +const { + redirectStatusSet, + nullBodyStatus, + DOMException +} = require('./constants') +const { kState, kHeaders, kGuard, kRealm } = require('./symbols') +const { webidl } = require('./webidl') +const { FormData } = require('./formdata') +const { getGlobalOrigin } = require('./global') +const { URLSerializer } = require('./dataURL') +const { kHeadersList, kConstruct } = require('../core/symbols') +const assert = require('assert') +const { types } = require('util') + +const ReadableStream = globalThis.ReadableStream || require('stream/web').ReadableStream +const textEncoder = new TextEncoder('utf-8') + +// https://fetch.spec.whatwg.org/#response-class +class Response { + // Creates network error Response. + static error () { + // TODO + const relevantRealm = { settingsObject: {} } + + // The static error() method steps are to return the result of creating a + // Response object, given a new network error, "immutable", and this’s + // relevant Realm. + const responseObject = new Response() + responseObject[kState] = makeNetworkError() + responseObject[kRealm] = relevantRealm + responseObject[kHeaders][kHeadersList] = responseObject[kState].headersList + responseObject[kHeaders][kGuard] = 'immutable' + responseObject[kHeaders][kRealm] = relevantRealm + return responseObject + } + + // https://fetch.spec.whatwg.org/#dom-response-json + static json (data, init = {}) { + webidl.argumentLengthCheck(arguments, 1, { header: 'Response.json' }) + + if (init !== null) { + init = webidl.converters.ResponseInit(init) + } + + // 1. Let bytes the result of running serialize a JavaScript value to JSON bytes on data. + const bytes = textEncoder.encode( + serializeJavascriptValueToJSONString(data) + ) + + // 2. Let body be the result of extracting bytes. + const body = extractBody(bytes) + + // 3. Let responseObject be the result of creating a Response object, given a new response, + // "response", and this’s relevant Realm. + const relevantRealm = { settingsObject: {} } + const responseObject = new Response() + responseObject[kRealm] = relevantRealm + responseObject[kHeaders][kGuard] = 'response' + responseObject[kHeaders][kRealm] = relevantRealm + + // 4. Perform initialize a response given responseObject, init, and (body, "application/json"). + initializeResponse(responseObject, init, { body: body[0], type: 'application/json' }) + + // 5. Return responseObject. + return responseObject + } + + // Creates a redirect Response that redirects to url with status status. + static redirect (url, status = 302) { + const relevantRealm = { settingsObject: {} } + + webidl.argumentLengthCheck(arguments, 1, { header: 'Response.redirect' }) + + url = webidl.converters.USVString(url) + status = webidl.converters['unsigned short'](status) + + // 1. Let parsedURL be the result of parsing url with current settings + // object’s API base URL. + // 2. If parsedURL is failure, then throw a TypeError. + // TODO: base-URL? + let parsedURL + try { + parsedURL = new URL(url, getGlobalOrigin()) + } catch (err) { + throw Object.assign(new TypeError('Failed to parse URL from ' + url), { + cause: err + }) + } + + // 3. If status is not a redirect status, then throw a RangeError. + if (!redirectStatusSet.has(status)) { + throw new RangeError('Invalid status code ' + status) + } + + // 4. Let responseObject be the result of creating a Response object, + // given a new response, "immutable", and this’s relevant Realm. + const responseObject = new Response() + responseObject[kRealm] = relevantRealm + responseObject[kHeaders][kGuard] = 'immutable' + responseObject[kHeaders][kRealm] = relevantRealm + + // 5. Set responseObject’s response’s status to status. + responseObject[kState].status = status + + // 6. Let value be parsedURL, serialized and isomorphic encoded. + const value = isomorphicEncode(URLSerializer(parsedURL)) + + // 7. Append `Location`/value to responseObject’s response’s header list. + responseObject[kState].headersList.append('location', value) + + // 8. Return responseObject. + return responseObject + } + + // https://fetch.spec.whatwg.org/#dom-response + constructor (body = null, init = {}) { + if (body !== null) { + body = webidl.converters.BodyInit(body) + } + + init = webidl.converters.ResponseInit(init) + + // TODO + this[kRealm] = { settingsObject: {} } + + // 1. Set this’s response to a new response. + this[kState] = makeResponse({}) + + // 2. Set this’s headers to a new Headers object with this’s relevant + // Realm, whose header list is this’s response’s header list and guard + // is "response". + this[kHeaders] = new Headers(kConstruct) + this[kHeaders][kGuard] = 'response' + this[kHeaders][kHeadersList] = this[kState].headersList + this[kHeaders][kRealm] = this[kRealm] + + // 3. Let bodyWithType be null. + let bodyWithType = null + + // 4. If body is non-null, then set bodyWithType to the result of extracting body. + if (body != null) { + const [extractedBody, type] = extractBody(body) + bodyWithType = { body: extractedBody, type } + } + + // 5. Perform initialize a response given this, init, and bodyWithType. + initializeResponse(this, init, bodyWithType) + } + + // Returns response’s type, e.g., "cors". + get type () { + webidl.brandCheck(this, Response) + + // The type getter steps are to return this’s response’s type. + return this[kState].type + } + + // Returns response’s URL, if it has one; otherwise the empty string. + get url () { + webidl.brandCheck(this, Response) + + const urlList = this[kState].urlList + + // The url getter steps are to return the empty string if this’s + // response’s URL is null; otherwise this’s response’s URL, + // serialized with exclude fragment set to true. + const url = urlList[urlList.length - 1] ?? null + + if (url === null) { + return '' + } + + return URLSerializer(url, true) + } + + // Returns whether response was obtained through a redirect. + get redirected () { + webidl.brandCheck(this, Response) + + // The redirected getter steps are to return true if this’s response’s URL + // list has more than one item; otherwise false. + return this[kState].urlList.length > 1 + } + + // Returns response’s status. + get status () { + webidl.brandCheck(this, Response) + + // The status getter steps are to return this’s response’s status. + return this[kState].status + } + + // Returns whether response’s status is an ok status. + get ok () { + webidl.brandCheck(this, Response) + + // The ok getter steps are to return true if this’s response’s status is an + // ok status; otherwise false. + return this[kState].status >= 200 && this[kState].status <= 299 + } + + // Returns response’s status message. + get statusText () { + webidl.brandCheck(this, Response) + + // The statusText getter steps are to return this’s response’s status + // message. + return this[kState].statusText + } + + // Returns response’s headers as Headers. + get headers () { + webidl.brandCheck(this, Response) + + // The headers getter steps are to return this’s headers. + return this[kHeaders] + } + + get body () { + webidl.brandCheck(this, Response) + + return this[kState].body ? this[kState].body.stream : null + } + + get bodyUsed () { + webidl.brandCheck(this, Response) + + return !!this[kState].body && util.isDisturbed(this[kState].body.stream) + } + + // Returns a clone of response. + clone () { + webidl.brandCheck(this, Response) + + // 1. If this is unusable, then throw a TypeError. + if (this.bodyUsed || (this.body && this.body.locked)) { + throw webidl.errors.exception({ + header: 'Response.clone', + message: 'Body has already been consumed.' + }) + } + + // 2. Let clonedResponse be the result of cloning this’s response. + const clonedResponse = cloneResponse(this[kState]) + + // 3. Return the result of creating a Response object, given + // clonedResponse, this’s headers’s guard, and this’s relevant Realm. + const clonedResponseObject = new Response() + clonedResponseObject[kState] = clonedResponse + clonedResponseObject[kRealm] = this[kRealm] + clonedResponseObject[kHeaders][kHeadersList] = clonedResponse.headersList + clonedResponseObject[kHeaders][kGuard] = this[kHeaders][kGuard] + clonedResponseObject[kHeaders][kRealm] = this[kHeaders][kRealm] + + return clonedResponseObject + } +} + +mixinBody(Response) + +Object.defineProperties(Response.prototype, { + type: kEnumerableProperty, + url: kEnumerableProperty, + status: kEnumerableProperty, + ok: kEnumerableProperty, + redirected: kEnumerableProperty, + statusText: kEnumerableProperty, + headers: kEnumerableProperty, + clone: kEnumerableProperty, + body: kEnumerableProperty, + bodyUsed: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'Response', + configurable: true + } +}) + +Object.defineProperties(Response, { + json: kEnumerableProperty, + redirect: kEnumerableProperty, + error: kEnumerableProperty +}) + +// https://fetch.spec.whatwg.org/#concept-response-clone +function cloneResponse (response) { + // To clone a response response, run these steps: + + // 1. If response is a filtered response, then return a new identical + // filtered response whose internal response is a clone of response’s + // internal response. + if (response.internalResponse) { + return filterResponse( + cloneResponse(response.internalResponse), + response.type + ) + } + + // 2. Let newResponse be a copy of response, except for its body. + const newResponse = makeResponse({ ...response, body: null }) + + // 3. If response’s body is non-null, then set newResponse’s body to the + // result of cloning response’s body. + if (response.body != null) { + newResponse.body = cloneBody(response.body) + } + + // 4. Return newResponse. + return newResponse +} + +function makeResponse (init) { + return { + aborted: false, + rangeRequested: false, + timingAllowPassed: false, + requestIncludesCredentials: false, + type: 'default', + status: 200, + timingInfo: null, + cacheState: '', + statusText: '', + ...init, + headersList: init.headersList + ? new HeadersList(init.headersList) + : new HeadersList(), + urlList: init.urlList ? [...init.urlList] : [] + } +} + +function makeNetworkError (reason) { + const isError = isErrorLike(reason) + return makeResponse({ + type: 'error', + status: 0, + error: isError + ? reason + : new Error(reason ? String(reason) : reason), + aborted: reason && reason.name === 'AbortError' + }) +} + +function makeFilteredResponse (response, state) { + state = { + internalResponse: response, + ...state + } + + return new Proxy(response, { + get (target, p) { + return p in state ? state[p] : target[p] + }, + set (target, p, value) { + assert(!(p in state)) + target[p] = value + return true + } + }) +} + +// https://fetch.spec.whatwg.org/#concept-filtered-response +function filterResponse (response, type) { + // Set response to the following filtered response with response as its + // internal response, depending on request’s response tainting: + if (type === 'basic') { + // A basic filtered response is a filtered response whose type is "basic" + // and header list excludes any headers in internal response’s header list + // whose name is a forbidden response-header name. + + // Note: undici does not implement forbidden response-header names + return makeFilteredResponse(response, { + type: 'basic', + headersList: response.headersList + }) + } else if (type === 'cors') { + // A CORS filtered response is a filtered response whose type is "cors" + // and header list excludes any headers in internal response’s header + // list whose name is not a CORS-safelisted response-header name, given + // internal response’s CORS-exposed header-name list. + + // Note: undici does not implement CORS-safelisted response-header names + return makeFilteredResponse(response, { + type: 'cors', + headersList: response.headersList + }) + } else if (type === 'opaque') { + // An opaque filtered response is a filtered response whose type is + // "opaque", URL list is the empty list, status is 0, status message + // is the empty byte sequence, header list is empty, and body is null. + + return makeFilteredResponse(response, { + type: 'opaque', + urlList: Object.freeze([]), + status: 0, + statusText: '', + body: null + }) + } else if (type === 'opaqueredirect') { + // An opaque-redirect filtered response is a filtered response whose type + // is "opaqueredirect", status is 0, status message is the empty byte + // sequence, header list is empty, and body is null. + + return makeFilteredResponse(response, { + type: 'opaqueredirect', + status: 0, + statusText: '', + headersList: [], + body: null + }) + } else { + assert(false) + } +} + +// https://fetch.spec.whatwg.org/#appropriate-network-error +function makeAppropriateNetworkError (fetchParams, err = null) { + // 1. Assert: fetchParams is canceled. + assert(isCancelled(fetchParams)) + + // 2. Return an aborted network error if fetchParams is aborted; + // otherwise return a network error. + return isAborted(fetchParams) + ? makeNetworkError(Object.assign(new DOMException('The operation was aborted.', 'AbortError'), { cause: err })) + : makeNetworkError(Object.assign(new DOMException('Request was cancelled.'), { cause: err })) +} + +// https://whatpr.org/fetch/1392.html#initialize-a-response +function initializeResponse (response, init, body) { + // 1. If init["status"] is not in the range 200 to 599, inclusive, then + // throw a RangeError. + if (init.status !== null && (init.status < 200 || init.status > 599)) { + throw new RangeError('init["status"] must be in the range of 200 to 599, inclusive.') + } + + // 2. If init["statusText"] does not match the reason-phrase token production, + // then throw a TypeError. + if ('statusText' in init && init.statusText != null) { + // See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2: + // reason-phrase = *( HTAB / SP / VCHAR / obs-text ) + if (!isValidReasonPhrase(String(init.statusText))) { + throw new TypeError('Invalid statusText') + } + } + + // 3. Set response’s response’s status to init["status"]. + if ('status' in init && init.status != null) { + response[kState].status = init.status + } + + // 4. Set response’s response’s status message to init["statusText"]. + if ('statusText' in init && init.statusText != null) { + response[kState].statusText = init.statusText + } + + // 5. If init["headers"] exists, then fill response’s headers with init["headers"]. + if ('headers' in init && init.headers != null) { + fill(response[kHeaders], init.headers) + } + + // 6. If body was given, then: + if (body) { + // 1. If response's status is a null body status, then throw a TypeError. + if (nullBodyStatus.includes(response.status)) { + throw webidl.errors.exception({ + header: 'Response constructor', + message: 'Invalid response status code ' + response.status + }) + } + + // 2. Set response's body to body's body. + response[kState].body = body.body + + // 3. If body's type is non-null and response's header list does not contain + // `Content-Type`, then append (`Content-Type`, body's type) to response's header list. + if (body.type != null && !response[kState].headersList.contains('Content-Type')) { + response[kState].headersList.append('content-type', body.type) + } + } +} + +webidl.converters.ReadableStream = webidl.interfaceConverter( + ReadableStream +) + +webidl.converters.FormData = webidl.interfaceConverter( + FormData +) + +webidl.converters.URLSearchParams = webidl.interfaceConverter( + URLSearchParams +) + +// https://fetch.spec.whatwg.org/#typedefdef-xmlhttprequestbodyinit +webidl.converters.XMLHttpRequestBodyInit = function (V) { + if (typeof V === 'string') { + return webidl.converters.USVString(V) + } + + if (isBlobLike(V)) { + return webidl.converters.Blob(V, { strict: false }) + } + + if (types.isArrayBuffer(V) || types.isTypedArray(V) || types.isDataView(V)) { + return webidl.converters.BufferSource(V) + } + + if (util.isFormDataLike(V)) { + return webidl.converters.FormData(V, { strict: false }) + } + + if (V instanceof URLSearchParams) { + return webidl.converters.URLSearchParams(V) + } + + return webidl.converters.DOMString(V) +} + +// https://fetch.spec.whatwg.org/#bodyinit +webidl.converters.BodyInit = function (V) { + if (V instanceof ReadableStream) { + return webidl.converters.ReadableStream(V) + } + + // Note: the spec doesn't include async iterables, + // this is an undici extension. + if (V?.[Symbol.asyncIterator]) { + return V + } + + return webidl.converters.XMLHttpRequestBodyInit(V) +} + +webidl.converters.ResponseInit = webidl.dictionaryConverter([ + { + key: 'status', + converter: webidl.converters['unsigned short'], + defaultValue: 200 + }, + { + key: 'statusText', + converter: webidl.converters.ByteString, + defaultValue: '' + }, + { + key: 'headers', + converter: webidl.converters.HeadersInit + } +]) + +module.exports = { + makeNetworkError, + makeResponse, + makeAppropriateNetworkError, + filterResponse, + Response, + cloneResponse +} diff --git a/lib/fetch/symbols.js b/lib/fetch/symbols.js new file mode 100644 index 0000000..0b947d5 --- /dev/null +++ b/lib/fetch/symbols.js @@ -0,0 +1,10 @@ +'use strict' + +module.exports = { + kUrl: Symbol('url'), + kHeaders: Symbol('headers'), + kSignal: Symbol('signal'), + kState: Symbol('state'), + kGuard: Symbol('guard'), + kRealm: Symbol('realm') +} diff --git a/lib/fetch/util.js b/lib/fetch/util.js new file mode 100644 index 0000000..ede4ec4 --- /dev/null +++ b/lib/fetch/util.js @@ -0,0 +1,1144 @@ +'use strict' + +const { redirectStatusSet, referrerPolicySet: referrerPolicyTokens, badPortsSet } = require('./constants') +const { getGlobalOrigin } = require('./global') +const { performance } = require('perf_hooks') +const { isBlobLike, toUSVString, ReadableStreamFrom } = require('../core/util') +const assert = require('assert') +const { isUint8Array } = require('util/types') + +let supportedHashes = [] + +// https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable +/** @type {import('crypto')|undefined} */ +let crypto + +try { + crypto = require('crypto') + const possibleRelevantHashes = ['sha256', 'sha384', 'sha512'] + supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash)) +/* c8 ignore next 3 */ +} catch { +} + +function responseURL (response) { + // https://fetch.spec.whatwg.org/#responses + // A response has an associated URL. It is a pointer to the last URL + // in response’s URL list and null if response’s URL list is empty. + const urlList = response.urlList + const length = urlList.length + return length === 0 ? null : urlList[length - 1].toString() +} + +// https://fetch.spec.whatwg.org/#concept-response-location-url +function responseLocationURL (response, requestFragment) { + // 1. If response’s status is not a redirect status, then return null. + if (!redirectStatusSet.has(response.status)) { + return null + } + + // 2. Let location be the result of extracting header list values given + // `Location` and response’s header list. + let location = response.headersList.get('location') + + // 3. If location is a header value, then set location to the result of + // parsing location with response’s URL. + if (location !== null && isValidHeaderValue(location)) { + location = new URL(location, responseURL(response)) + } + + // 4. If location is a URL whose fragment is null, then set location’s + // fragment to requestFragment. + if (location && !location.hash) { + location.hash = requestFragment + } + + // 5. Return location. + return location +} + +/** @returns {URL} */ +function requestCurrentURL (request) { + return request.urlList[request.urlList.length - 1] +} + +function requestBadPort (request) { + // 1. Let url be request’s current URL. + const url = requestCurrentURL(request) + + // 2. If url’s scheme is an HTTP(S) scheme and url’s port is a bad port, + // then return blocked. + if (urlIsHttpHttpsScheme(url) && badPortsSet.has(url.port)) { + return 'blocked' + } + + // 3. Return allowed. + return 'allowed' +} + +function isErrorLike (object) { + return object instanceof Error || ( + object?.constructor?.name === 'Error' || + object?.constructor?.name === 'DOMException' + ) +} + +// Check whether |statusText| is a ByteString and +// matches the Reason-Phrase token production. +// RFC 2616: https://tools.ietf.org/html/rfc2616 +// RFC 7230: https://tools.ietf.org/html/rfc7230 +// "reason-phrase = *( HTAB / SP / VCHAR / obs-text )" +// https://github.com/chromium/chromium/blob/94.0.4604.1/third_party/blink/renderer/core/fetch/response.cc#L116 +function isValidReasonPhrase (statusText) { + for (let i = 0; i < statusText.length; ++i) { + const c = statusText.charCodeAt(i) + if ( + !( + ( + c === 0x09 || // HTAB + (c >= 0x20 && c <= 0x7e) || // SP / VCHAR + (c >= 0x80 && c <= 0xff) + ) // obs-text + ) + ) { + return false + } + } + return true +} + +/** + * @see https://tools.ietf.org/html/rfc7230#section-3.2.6 + * @param {number} c + */ +function isTokenCharCode (c) { + switch (c) { + case 0x22: + case 0x28: + case 0x29: + case 0x2c: + case 0x2f: + case 0x3a: + case 0x3b: + case 0x3c: + case 0x3d: + case 0x3e: + case 0x3f: + case 0x40: + case 0x5b: + case 0x5c: + case 0x5d: + case 0x7b: + case 0x7d: + // DQUOTE and "(),/:;<=>?@[\]{}" + return false + default: + // VCHAR %x21-7E + return c >= 0x21 && c <= 0x7e + } +} + +/** + * @param {string} characters + */ +function isValidHTTPToken (characters) { + if (characters.length === 0) { + return false + } + for (let i = 0; i < characters.length; ++i) { + if (!isTokenCharCode(characters.charCodeAt(i))) { + return false + } + } + return true +} + +/** + * @see https://fetch.spec.whatwg.org/#header-name + * @param {string} potentialValue + */ +function isValidHeaderName (potentialValue) { + return isValidHTTPToken(potentialValue) +} + +/** + * @see https://fetch.spec.whatwg.org/#header-value + * @param {string} potentialValue + */ +function isValidHeaderValue (potentialValue) { + // - Has no leading or trailing HTTP tab or space bytes. + // - Contains no 0x00 (NUL) or HTTP newline bytes. + if ( + potentialValue.startsWith('\t') || + potentialValue.startsWith(' ') || + potentialValue.endsWith('\t') || + potentialValue.endsWith(' ') + ) { + return false + } + + if ( + potentialValue.includes('\0') || + potentialValue.includes('\r') || + potentialValue.includes('\n') + ) { + return false + } + + return true +} + +// https://w3c.github.io/webappsec-referrer-policy/#set-requests-referrer-policy-on-redirect +function setRequestReferrerPolicyOnRedirect (request, actualResponse) { + // Given a request request and a response actualResponse, this algorithm + // updates request’s referrer policy according to the Referrer-Policy + // header (if any) in actualResponse. + + // 1. Let policy be the result of executing § 8.1 Parse a referrer policy + // from a Referrer-Policy header on actualResponse. + + // 8.1 Parse a referrer policy from a Referrer-Policy header + // 1. Let policy-tokens be the result of extracting header list values given `Referrer-Policy` and response’s header list. + const { headersList } = actualResponse + // 2. Let policy be the empty string. + // 3. For each token in policy-tokens, if token is a referrer policy and token is not the empty string, then set policy to token. + // 4. Return policy. + const policyHeader = (headersList.get('referrer-policy') ?? '').split(',') + + // Note: As the referrer-policy can contain multiple policies + // separated by comma, we need to loop through all of them + // and pick the first valid one. + // Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#specify_a_fallback_policy + let policy = '' + if (policyHeader.length > 0) { + // The right-most policy takes precedence. + // The left-most policy is the fallback. + for (let i = policyHeader.length; i !== 0; i--) { + const token = policyHeader[i - 1].trim() + if (referrerPolicyTokens.has(token)) { + policy = token + break + } + } + } + + // 2. If policy is not the empty string, then set request’s referrer policy to policy. + if (policy !== '') { + request.referrerPolicy = policy + } +} + +// https://fetch.spec.whatwg.org/#cross-origin-resource-policy-check +function crossOriginResourcePolicyCheck () { + // TODO + return 'allowed' +} + +// https://fetch.spec.whatwg.org/#concept-cors-check +function corsCheck () { + // TODO + return 'success' +} + +// https://fetch.spec.whatwg.org/#concept-tao-check +function TAOCheck () { + // TODO + return 'success' +} + +function appendFetchMetadata (httpRequest) { + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-dest-header + // TODO + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-mode-header + + // 1. Assert: r’s url is a potentially trustworthy URL. + // TODO + + // 2. Let header be a Structured Header whose value is a token. + let header = null + + // 3. Set header’s value to r’s mode. + header = httpRequest.mode + + // 4. Set a structured field value `Sec-Fetch-Mode`/header in r’s header list. + httpRequest.headersList.set('sec-fetch-mode', header) + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-site-header + // TODO + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-user-header + // TODO +} + +// https://fetch.spec.whatwg.org/#append-a-request-origin-header +function appendRequestOriginHeader (request) { + // 1. Let serializedOrigin be the result of byte-serializing a request origin with request. + let serializedOrigin = request.origin + + // 2. If request’s response tainting is "cors" or request’s mode is "websocket", then append (`Origin`, serializedOrigin) to request’s header list. + if (request.responseTainting === 'cors' || request.mode === 'websocket') { + if (serializedOrigin) { + request.headersList.append('origin', serializedOrigin) + } + + // 3. Otherwise, if request’s method is neither `GET` nor `HEAD`, then: + } else if (request.method !== 'GET' && request.method !== 'HEAD') { + // 1. Switch on request’s referrer policy: + switch (request.referrerPolicy) { + case 'no-referrer': + // Set serializedOrigin to `null`. + serializedOrigin = null + break + case 'no-referrer-when-downgrade': + case 'strict-origin': + case 'strict-origin-when-cross-origin': + // If request’s origin is a tuple origin, its scheme is "https", and request’s current URL’s scheme is not "https", then set serializedOrigin to `null`. + if (request.origin && urlHasHttpsScheme(request.origin) && !urlHasHttpsScheme(requestCurrentURL(request))) { + serializedOrigin = null + } + break + case 'same-origin': + // If request’s origin is not same origin with request’s current URL’s origin, then set serializedOrigin to `null`. + if (!sameOrigin(request, requestCurrentURL(request))) { + serializedOrigin = null + } + break + default: + // Do nothing. + } + + if (serializedOrigin) { + // 2. Append (`Origin`, serializedOrigin) to request’s header list. + request.headersList.append('origin', serializedOrigin) + } + } +} + +function coarsenedSharedCurrentTime (crossOriginIsolatedCapability) { + // TODO + return performance.now() +} + +// https://fetch.spec.whatwg.org/#create-an-opaque-timing-info +function createOpaqueTimingInfo (timingInfo) { + return { + startTime: timingInfo.startTime ?? 0, + redirectStartTime: 0, + redirectEndTime: 0, + postRedirectStartTime: timingInfo.startTime ?? 0, + finalServiceWorkerStartTime: 0, + finalNetworkResponseStartTime: 0, + finalNetworkRequestStartTime: 0, + endTime: 0, + encodedBodySize: 0, + decodedBodySize: 0, + finalConnectionTimingInfo: null + } +} + +// https://html.spec.whatwg.org/multipage/origin.html#policy-container +function makePolicyContainer () { + // Note: the fetch spec doesn't make use of embedder policy or CSP list + return { + referrerPolicy: 'strict-origin-when-cross-origin' + } +} + +// https://html.spec.whatwg.org/multipage/origin.html#clone-a-policy-container +function clonePolicyContainer (policyContainer) { + return { + referrerPolicy: policyContainer.referrerPolicy + } +} + +// https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer +function determineRequestsReferrer (request) { + // 1. Let policy be request's referrer policy. + const policy = request.referrerPolicy + + // Note: policy cannot (shouldn't) be null or an empty string. + assert(policy) + + // 2. Let environment be request’s client. + + let referrerSource = null + + // 3. Switch on request’s referrer: + if (request.referrer === 'client') { + // Note: node isn't a browser and doesn't implement document/iframes, + // so we bypass this step and replace it with our own. + + const globalOrigin = getGlobalOrigin() + + if (!globalOrigin || globalOrigin.origin === 'null') { + return 'no-referrer' + } + + // note: we need to clone it as it's mutated + referrerSource = new URL(globalOrigin) + } else if (request.referrer instanceof URL) { + // Let referrerSource be request’s referrer. + referrerSource = request.referrer + } + + // 4. Let request’s referrerURL be the result of stripping referrerSource for + // use as a referrer. + let referrerURL = stripURLForReferrer(referrerSource) + + // 5. Let referrerOrigin be the result of stripping referrerSource for use as + // a referrer, with the origin-only flag set to true. + const referrerOrigin = stripURLForReferrer(referrerSource, true) + + // 6. If the result of serializing referrerURL is a string whose length is + // greater than 4096, set referrerURL to referrerOrigin. + if (referrerURL.toString().length > 4096) { + referrerURL = referrerOrigin + } + + const areSameOrigin = sameOrigin(request, referrerURL) + const isNonPotentiallyTrustWorthy = isURLPotentiallyTrustworthy(referrerURL) && + !isURLPotentiallyTrustworthy(request.url) + + // 8. Execute the switch statements corresponding to the value of policy: + switch (policy) { + case 'origin': return referrerOrigin != null ? referrerOrigin : stripURLForReferrer(referrerSource, true) + case 'unsafe-url': return referrerURL + case 'same-origin': + return areSameOrigin ? referrerOrigin : 'no-referrer' + case 'origin-when-cross-origin': + return areSameOrigin ? referrerURL : referrerOrigin + case 'strict-origin-when-cross-origin': { + const currentURL = requestCurrentURL(request) + + // 1. If the origin of referrerURL and the origin of request’s current + // URL are the same, then return referrerURL. + if (sameOrigin(referrerURL, currentURL)) { + return referrerURL + } + + // 2. If referrerURL is a potentially trustworthy URL and request’s + // current URL is not a potentially trustworthy URL, then return no + // referrer. + if (isURLPotentiallyTrustworthy(referrerURL) && !isURLPotentiallyTrustworthy(currentURL)) { + return 'no-referrer' + } + + // 3. Return referrerOrigin. + return referrerOrigin + } + case 'strict-origin': // eslint-disable-line + /** + * 1. If referrerURL is a potentially trustworthy URL and + * request’s current URL is not a potentially trustworthy URL, + * then return no referrer. + * 2. Return referrerOrigin + */ + case 'no-referrer-when-downgrade': // eslint-disable-line + /** + * 1. If referrerURL is a potentially trustworthy URL and + * request’s current URL is not a potentially trustworthy URL, + * then return no referrer. + * 2. Return referrerOrigin + */ + + default: // eslint-disable-line + return isNonPotentiallyTrustWorthy ? 'no-referrer' : referrerOrigin + } +} + +/** + * @see https://w3c.github.io/webappsec-referrer-policy/#strip-url + * @param {URL} url + * @param {boolean|undefined} originOnly + */ +function stripURLForReferrer (url, originOnly) { + // 1. Assert: url is a URL. + assert(url instanceof URL) + + // 2. If url’s scheme is a local scheme, then return no referrer. + if (url.protocol === 'file:' || url.protocol === 'about:' || url.protocol === 'blank:') { + return 'no-referrer' + } + + // 3. Set url’s username to the empty string. + url.username = '' + + // 4. Set url’s password to the empty string. + url.password = '' + + // 5. Set url’s fragment to null. + url.hash = '' + + // 6. If the origin-only flag is true, then: + if (originOnly) { + // 1. Set url’s path to « the empty string ». + url.pathname = '' + + // 2. Set url’s query to null. + url.search = '' + } + + // 7. Return url. + return url +} + +function isURLPotentiallyTrustworthy (url) { + if (!(url instanceof URL)) { + return false + } + + // If child of about, return true + if (url.href === 'about:blank' || url.href === 'about:srcdoc') { + return true + } + + // If scheme is data, return true + if (url.protocol === 'data:') return true + + // If file, return true + if (url.protocol === 'file:') return true + + return isOriginPotentiallyTrustworthy(url.origin) + + function isOriginPotentiallyTrustworthy (origin) { + // If origin is explicitly null, return false + if (origin == null || origin === 'null') return false + + const originAsURL = new URL(origin) + + // If secure, return true + if (originAsURL.protocol === 'https:' || originAsURL.protocol === 'wss:') { + return true + } + + // If localhost or variants, return true + if (/^127(?:\.[0-9]+){0,2}\.[0-9]+$|^\[(?:0*:)*?:?0*1\]$/.test(originAsURL.hostname) || + (originAsURL.hostname === 'localhost' || originAsURL.hostname.includes('localhost.')) || + (originAsURL.hostname.endsWith('.localhost'))) { + return true + } + + // If any other, return false + return false + } +} + +/** + * @see https://w3c.github.io/webappsec-subresource-integrity/#does-response-match-metadatalist + * @param {Uint8Array} bytes + * @param {string} metadataList + */ +function bytesMatch (bytes, metadataList) { + // If node is not built with OpenSSL support, we cannot check + // a request's integrity, so allow it by default (the spec will + // allow requests if an invalid hash is given, as precedence). + /* istanbul ignore if: only if node is built with --without-ssl */ + if (crypto === undefined) { + return true + } + + // 1. Let parsedMetadata be the result of parsing metadataList. + const parsedMetadata = parseMetadata(metadataList) + + // 2. If parsedMetadata is no metadata, return true. + if (parsedMetadata === 'no metadata') { + return true + } + + // 3. If response is not eligible for integrity validation, return false. + // TODO + + // 4. If parsedMetadata is the empty set, return true. + if (parsedMetadata.length === 0) { + return true + } + + // 5. Let metadata be the result of getting the strongest + // metadata from parsedMetadata. + const strongest = getStrongestMetadata(parsedMetadata) + const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest) + + // 6. For each item in metadata: + for (const item of metadata) { + // 1. Let algorithm be the alg component of item. + const algorithm = item.algo + + // 2. Let expectedValue be the val component of item. + const expectedValue = item.hash + + // See https://github.com/web-platform-tests/wpt/commit/e4c5cc7a5e48093220528dfdd1c4012dc3837a0e + // "be liberal with padding". This is annoying, and it's not even in the spec. + + // 3. Let actualValue be the result of applying algorithm to bytes. + let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64') + + if (actualValue[actualValue.length - 1] === '=') { + if (actualValue[actualValue.length - 2] === '=') { + actualValue = actualValue.slice(0, -2) + } else { + actualValue = actualValue.slice(0, -1) + } + } + + // 4. If actualValue is a case-sensitive match for expectedValue, + // return true. + if (compareBase64Mixed(actualValue, expectedValue)) { + return true + } + } + + // 7. Return false. + return false +} + +// https://w3c.github.io/webappsec-subresource-integrity/#grammardef-hash-with-options +// https://www.w3.org/TR/CSP2/#source-list-syntax +// https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 +const parseHashWithOptions = /(?sha256|sha384|sha512)-((?[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i + +/** + * @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata + * @param {string} metadata + */ +function parseMetadata (metadata) { + // 1. Let result be the empty set. + /** @type {{ algo: string, hash: string }[]} */ + const result = [] + + // 2. Let empty be equal to true. + let empty = true + + // 3. For each token returned by splitting metadata on spaces: + for (const token of metadata.split(' ')) { + // 1. Set empty to false. + empty = false + + // 2. Parse token as a hash-with-options. + const parsedToken = parseHashWithOptions.exec(token) + + // 3. If token does not parse, continue to the next token. + if ( + parsedToken === null || + parsedToken.groups === undefined || + parsedToken.groups.algo === undefined + ) { + // Note: Chromium blocks the request at this point, but Firefox + // gives a warning that an invalid integrity was given. The + // correct behavior is to ignore these, and subsequently not + // check the integrity of the resource. + continue + } + + // 4. Let algorithm be the hash-algo component of token. + const algorithm = parsedToken.groups.algo.toLowerCase() + + // 5. If algorithm is a hash function recognized by the user + // agent, add the parsed token to result. + if (supportedHashes.includes(algorithm)) { + result.push(parsedToken.groups) + } + } + + // 4. Return no metadata if empty is true, otherwise return result. + if (empty === true) { + return 'no metadata' + } + + return result +} + +/** + * @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList + */ +function getStrongestMetadata (metadataList) { + // Let algorithm be the algo component of the first item in metadataList. + // Can be sha256 + let algorithm = metadataList[0].algo + // If the algorithm is sha512, then it is the strongest + // and we can return immediately + if (algorithm[3] === '5') { + return algorithm + } + + for (let i = 1; i < metadataList.length; ++i) { + const metadata = metadataList[i] + // If the algorithm is sha512, then it is the strongest + // and we can break the loop immediately + if (metadata.algo[3] === '5') { + algorithm = 'sha512' + break + // If the algorithm is sha384, then a potential sha256 or sha384 is ignored + } else if (algorithm[3] === '3') { + continue + // algorithm is sha256, check if algorithm is sha384 and if so, set it as + // the strongest + } else if (metadata.algo[3] === '3') { + algorithm = 'sha384' + } + } + return algorithm +} + +function filterMetadataListByAlgorithm (metadataList, algorithm) { + if (metadataList.length === 1) { + return metadataList + } + + let pos = 0 + for (let i = 0; i < metadataList.length; ++i) { + if (metadataList[i].algo === algorithm) { + metadataList[pos++] = metadataList[i] + } + } + + metadataList.length = pos + + return metadataList +} + +/** + * Compares two base64 strings, allowing for base64url + * in the second string. + * +* @param {string} actualValue always base64 + * @param {string} expectedValue base64 or base64url + * @returns {boolean} + */ +function compareBase64Mixed (actualValue, expectedValue) { + if (actualValue.length !== expectedValue.length) { + return false + } + for (let i = 0; i < actualValue.length; ++i) { + if (actualValue[i] !== expectedValue[i]) { + if ( + (actualValue[i] === '+' && expectedValue[i] === '-') || + (actualValue[i] === '/' && expectedValue[i] === '_') + ) { + continue + } + return false + } + } + + return true +} + +// https://w3c.github.io/webappsec-upgrade-insecure-requests/#upgrade-request +function tryUpgradeRequestToAPotentiallyTrustworthyURL (request) { + // TODO +} + +/** + * @link {https://html.spec.whatwg.org/multipage/origin.html#same-origin} + * @param {URL} A + * @param {URL} B + */ +function sameOrigin (A, B) { + // 1. If A and B are the same opaque origin, then return true. + if (A.origin === B.origin && A.origin === 'null') { + return true + } + + // 2. If A and B are both tuple origins and their schemes, + // hosts, and port are identical, then return true. + if (A.protocol === B.protocol && A.hostname === B.hostname && A.port === B.port) { + return true + } + + // 3. Return false. + return false +} + +function createDeferredPromise () { + let res + let rej + const promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + + return { promise, resolve: res, reject: rej } +} + +function isAborted (fetchParams) { + return fetchParams.controller.state === 'aborted' +} + +function isCancelled (fetchParams) { + return fetchParams.controller.state === 'aborted' || + fetchParams.controller.state === 'terminated' +} + +const normalizeMethodRecord = { + delete: 'DELETE', + DELETE: 'DELETE', + get: 'GET', + GET: 'GET', + head: 'HEAD', + HEAD: 'HEAD', + options: 'OPTIONS', + OPTIONS: 'OPTIONS', + post: 'POST', + POST: 'POST', + put: 'PUT', + PUT: 'PUT' +} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(normalizeMethodRecord, null) + +/** + * @see https://fetch.spec.whatwg.org/#concept-method-normalize + * @param {string} method + */ +function normalizeMethod (method) { + return normalizeMethodRecord[method.toLowerCase()] ?? method +} + +// https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string +function serializeJavascriptValueToJSONString (value) { + // 1. Let result be ? Call(%JSON.stringify%, undefined, « value »). + const result = JSON.stringify(value) + + // 2. If result is undefined, then throw a TypeError. + if (result === undefined) { + throw new TypeError('Value is not JSON serializable') + } + + // 3. Assert: result is a string. + assert(typeof result === 'string') + + // 4. Return result. + return result +} + +// https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object +const esIteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + +/** + * @see https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + * @param {() => unknown[]} iterator + * @param {string} name name of the instance + * @param {'key'|'value'|'key+value'} kind + */ +function makeIterator (iterator, name, kind) { + const object = { + index: 0, + kind, + target: iterator + } + + const i = { + next () { + // 1. Let interface be the interface for which the iterator prototype object exists. + + // 2. Let thisValue be the this value. + + // 3. Let object be ? ToObject(thisValue). + + // 4. If object is a platform object, then perform a security + // check, passing: + + // 5. If object is not a default iterator object for interface, + // then throw a TypeError. + if (Object.getPrototypeOf(this) !== i) { + throw new TypeError( + `'next' called on an object that does not implement interface ${name} Iterator.` + ) + } + + // 6. Let index be object’s index. + // 7. Let kind be object’s kind. + // 8. Let values be object’s target's value pairs to iterate over. + const { index, kind, target } = object + const values = target() + + // 9. Let len be the length of values. + const len = values.length + + // 10. If index is greater than or equal to len, then return + // CreateIterResultObject(undefined, true). + if (index >= len) { + return { value: undefined, done: true } + } + + // 11. Let pair be the entry in values at index index. + const pair = values[index] + + // 12. Set object’s index to index + 1. + object.index = index + 1 + + // 13. Return the iterator result for pair and kind. + return iteratorResult(pair, kind) + }, + // The class string of an iterator prototype object for a given interface is the + // result of concatenating the identifier of the interface and the string " Iterator". + [Symbol.toStringTag]: `${name} Iterator` + } + + // The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%. + Object.setPrototypeOf(i, esIteratorPrototype) + // esIteratorPrototype needs to be the prototype of i + // which is the prototype of an empty object. Yes, it's confusing. + return Object.setPrototypeOf({}, i) +} + +// https://webidl.spec.whatwg.org/#iterator-result +function iteratorResult (pair, kind) { + let result + + // 1. Let result be a value determined by the value of kind: + switch (kind) { + case 'key': { + // 1. Let idlKey be pair’s key. + // 2. Let key be the result of converting idlKey to an + // ECMAScript value. + // 3. result is key. + result = pair[0] + break + } + case 'value': { + // 1. Let idlValue be pair’s value. + // 2. Let value be the result of converting idlValue to + // an ECMAScript value. + // 3. result is value. + result = pair[1] + break + } + case 'key+value': { + // 1. Let idlKey be pair’s key. + // 2. Let idlValue be pair’s value. + // 3. Let key be the result of converting idlKey to an + // ECMAScript value. + // 4. Let value be the result of converting idlValue to + // an ECMAScript value. + // 5. Let array be ! ArrayCreate(2). + // 6. Call ! CreateDataProperty(array, "0", key). + // 7. Call ! CreateDataProperty(array, "1", value). + // 8. result is array. + result = pair + break + } + } + + // 2. Return CreateIterResultObject(result, false). + return { value: result, done: false } +} + +/** + * @see https://fetch.spec.whatwg.org/#body-fully-read + */ +async function fullyReadBody (body, processBody, processBodyError) { + // 1. If taskDestination is null, then set taskDestination to + // the result of starting a new parallel queue. + + // 2. Let successSteps given a byte sequence bytes be to queue a + // fetch task to run processBody given bytes, with taskDestination. + const successSteps = processBody + + // 3. Let errorSteps be to queue a fetch task to run processBodyError, + // with taskDestination. + const errorSteps = processBodyError + + // 4. Let reader be the result of getting a reader for body’s stream. + // If that threw an exception, then run errorSteps with that + // exception and return. + let reader + + try { + reader = body.stream.getReader() + } catch (e) { + errorSteps(e) + return + } + + // 5. Read all bytes from reader, given successSteps and errorSteps. + try { + const result = await readAllBytes(reader) + successSteps(result) + } catch (e) { + errorSteps(e) + } +} + +/** @type {ReadableStream} */ +let ReadableStream = globalThis.ReadableStream + +function isReadableStreamLike (stream) { + if (!ReadableStream) { + ReadableStream = require('stream/web').ReadableStream + } + + return stream instanceof ReadableStream || ( + stream[Symbol.toStringTag] === 'ReadableStream' && + typeof stream.tee === 'function' + ) +} + +const MAXIMUM_ARGUMENT_LENGTH = 65535 + +/** + * @see https://infra.spec.whatwg.org/#isomorphic-decode + * @param {number[]|Uint8Array} input + */ +function isomorphicDecode (input) { + // 1. To isomorphic decode a byte sequence input, return a string whose code point + // length is equal to input’s length and whose code points have the same values + // as the values of input’s bytes, in the same order. + + if (input.length < MAXIMUM_ARGUMENT_LENGTH) { + return String.fromCharCode(...input) + } + + return input.reduce((previous, current) => previous + String.fromCharCode(current), '') +} + +/** + * @param {ReadableStreamController} controller + */ +function readableStreamClose (controller) { + try { + controller.close() + } catch (err) { + // TODO: add comment explaining why this error occurs. + if (!err.message.includes('Controller is already closed')) { + throw err + } + } +} + +/** + * @see https://infra.spec.whatwg.org/#isomorphic-encode + * @param {string} input + */ +function isomorphicEncode (input) { + // 1. Assert: input contains no code points greater than U+00FF. + for (let i = 0; i < input.length; i++) { + assert(input.charCodeAt(i) <= 0xFF) + } + + // 2. Return a byte sequence whose length is equal to input’s code + // point length and whose bytes have the same values as the + // values of input’s code points, in the same order + return input +} + +/** + * @see https://streams.spec.whatwg.org/#readablestreamdefaultreader-read-all-bytes + * @see https://streams.spec.whatwg.org/#read-loop + * @param {ReadableStreamDefaultReader} reader + */ +async function readAllBytes (reader) { + const bytes = [] + let byteLength = 0 + + while (true) { + const { done, value: chunk } = await reader.read() + + if (done) { + // 1. Call successSteps with bytes. + return Buffer.concat(bytes, byteLength) + } + + // 1. If chunk is not a Uint8Array object, call failureSteps + // with a TypeError and abort these steps. + if (!isUint8Array(chunk)) { + throw new TypeError('Received non-Uint8Array chunk') + } + + // 2. Append the bytes represented by chunk to bytes. + bytes.push(chunk) + byteLength += chunk.length + + // 3. Read-loop given reader, bytes, successSteps, and failureSteps. + } +} + +/** + * @see https://fetch.spec.whatwg.org/#is-local + * @param {URL} url + */ +function urlIsLocal (url) { + assert('protocol' in url) // ensure it's a url object + + const protocol = url.protocol + + return protocol === 'about:' || protocol === 'blob:' || protocol === 'data:' +} + +/** + * @param {string|URL} url + */ +function urlHasHttpsScheme (url) { + if (typeof url === 'string') { + return url.startsWith('https:') + } + + return url.protocol === 'https:' +} + +/** + * @see https://fetch.spec.whatwg.org/#http-scheme + * @param {URL} url + */ +function urlIsHttpHttpsScheme (url) { + assert('protocol' in url) // ensure it's a url object + + const protocol = url.protocol + + return protocol === 'http:' || protocol === 'https:' +} + +/** + * Fetch supports node >= 16.8.0, but Object.hasOwn was added in v16.9.0. + */ +const hasOwn = Object.hasOwn || ((dict, key) => Object.prototype.hasOwnProperty.call(dict, key)) + +module.exports = { + isAborted, + isCancelled, + createDeferredPromise, + ReadableStreamFrom, + toUSVString, + tryUpgradeRequestToAPotentiallyTrustworthyURL, + coarsenedSharedCurrentTime, + determineRequestsReferrer, + makePolicyContainer, + clonePolicyContainer, + appendFetchMetadata, + appendRequestOriginHeader, + TAOCheck, + corsCheck, + crossOriginResourcePolicyCheck, + createOpaqueTimingInfo, + setRequestReferrerPolicyOnRedirect, + isValidHTTPToken, + requestBadPort, + requestCurrentURL, + responseURL, + responseLocationURL, + isBlobLike, + isURLPotentiallyTrustworthy, + isValidReasonPhrase, + sameOrigin, + normalizeMethod, + serializeJavascriptValueToJSONString, + makeIterator, + isValidHeaderName, + isValidHeaderValue, + hasOwn, + isErrorLike, + fullyReadBody, + bytesMatch, + isReadableStreamLike, + readableStreamClose, + isomorphicEncode, + isomorphicDecode, + urlIsLocal, + urlHasHttpsScheme, + urlIsHttpHttpsScheme, + readAllBytes, + normalizeMethodRecord, + parseMetadata +} diff --git a/lib/fetch/webidl.js b/lib/fetch/webidl.js new file mode 100644 index 0000000..6fcf2ab --- /dev/null +++ b/lib/fetch/webidl.js @@ -0,0 +1,646 @@ +'use strict' + +const { types } = require('util') +const { hasOwn, toUSVString } = require('./util') + +/** @type {import('../../types/webidl').Webidl} */ +const webidl = {} +webidl.converters = {} +webidl.util = {} +webidl.errors = {} + +webidl.errors.exception = function (message) { + return new TypeError(`${message.header}: ${message.message}`) +} + +webidl.errors.conversionFailed = function (context) { + const plural = context.types.length === 1 ? '' : ' one of' + const message = + `${context.argument} could not be converted to` + + `${plural}: ${context.types.join(', ')}.` + + return webidl.errors.exception({ + header: context.prefix, + message + }) +} + +webidl.errors.invalidArgument = function (context) { + return webidl.errors.exception({ + header: context.prefix, + message: `"${context.value}" is an invalid ${context.type}.` + }) +} + +// https://webidl.spec.whatwg.org/#implements +webidl.brandCheck = function (V, I, opts = undefined) { + if (opts?.strict !== false && !(V instanceof I)) { + throw new TypeError('Illegal invocation') + } else { + return V?.[Symbol.toStringTag] === I.prototype[Symbol.toStringTag] + } +} + +webidl.argumentLengthCheck = function ({ length }, min, ctx) { + if (length < min) { + throw webidl.errors.exception({ + message: `${min} argument${min !== 1 ? 's' : ''} required, ` + + `but${length ? ' only' : ''} ${length} found.`, + ...ctx + }) + } +} + +webidl.illegalConstructor = function () { + throw webidl.errors.exception({ + header: 'TypeError', + message: 'Illegal constructor' + }) +} + +// https://tc39.es/ecma262/#sec-ecmascript-data-types-and-values +webidl.util.Type = function (V) { + switch (typeof V) { + case 'undefined': return 'Undefined' + case 'boolean': return 'Boolean' + case 'string': return 'String' + case 'symbol': return 'Symbol' + case 'number': return 'Number' + case 'bigint': return 'BigInt' + case 'function': + case 'object': { + if (V === null) { + return 'Null' + } + + return 'Object' + } + } +} + +// https://webidl.spec.whatwg.org/#abstract-opdef-converttoint +webidl.util.ConvertToInt = function (V, bitLength, signedness, opts = {}) { + let upperBound + let lowerBound + + // 1. If bitLength is 64, then: + if (bitLength === 64) { + // 1. Let upperBound be 2^53 − 1. + upperBound = Math.pow(2, 53) - 1 + + // 2. If signedness is "unsigned", then let lowerBound be 0. + if (signedness === 'unsigned') { + lowerBound = 0 + } else { + // 3. Otherwise let lowerBound be −2^53 + 1. + lowerBound = Math.pow(-2, 53) + 1 + } + } else if (signedness === 'unsigned') { + // 2. Otherwise, if signedness is "unsigned", then: + + // 1. Let lowerBound be 0. + lowerBound = 0 + + // 2. Let upperBound be 2^bitLength − 1. + upperBound = Math.pow(2, bitLength) - 1 + } else { + // 3. Otherwise: + + // 1. Let lowerBound be -2^bitLength − 1. + lowerBound = Math.pow(-2, bitLength) - 1 + + // 2. Let upperBound be 2^bitLength − 1 − 1. + upperBound = Math.pow(2, bitLength - 1) - 1 + } + + // 4. Let x be ? ToNumber(V). + let x = Number(V) + + // 5. If x is −0, then set x to +0. + if (x === 0) { + x = 0 + } + + // 6. If the conversion is to an IDL type associated + // with the [EnforceRange] extended attribute, then: + if (opts.enforceRange === true) { + // 1. If x is NaN, +∞, or −∞, then throw a TypeError. + if ( + Number.isNaN(x) || + x === Number.POSITIVE_INFINITY || + x === Number.NEGATIVE_INFINITY + ) { + throw webidl.errors.exception({ + header: 'Integer conversion', + message: `Could not convert ${V} to an integer.` + }) + } + + // 2. Set x to IntegerPart(x). + x = webidl.util.IntegerPart(x) + + // 3. If x < lowerBound or x > upperBound, then + // throw a TypeError. + if (x < lowerBound || x > upperBound) { + throw webidl.errors.exception({ + header: 'Integer conversion', + message: `Value must be between ${lowerBound}-${upperBound}, got ${x}.` + }) + } + + // 4. Return x. + return x + } + + // 7. If x is not NaN and the conversion is to an IDL + // type associated with the [Clamp] extended + // attribute, then: + if (!Number.isNaN(x) && opts.clamp === true) { + // 1. Set x to min(max(x, lowerBound), upperBound). + x = Math.min(Math.max(x, lowerBound), upperBound) + + // 2. Round x to the nearest integer, choosing the + // even integer if it lies halfway between two, + // and choosing +0 rather than −0. + if (Math.floor(x) % 2 === 0) { + x = Math.floor(x) + } else { + x = Math.ceil(x) + } + + // 3. Return x. + return x + } + + // 8. If x is NaN, +0, +∞, or −∞, then return +0. + if ( + Number.isNaN(x) || + (x === 0 && Object.is(0, x)) || + x === Number.POSITIVE_INFINITY || + x === Number.NEGATIVE_INFINITY + ) { + return 0 + } + + // 9. Set x to IntegerPart(x). + x = webidl.util.IntegerPart(x) + + // 10. Set x to x modulo 2^bitLength. + x = x % Math.pow(2, bitLength) + + // 11. If signedness is "signed" and x ≥ 2^bitLength − 1, + // then return x − 2^bitLength. + if (signedness === 'signed' && x >= Math.pow(2, bitLength) - 1) { + return x - Math.pow(2, bitLength) + } + + // 12. Otherwise, return x. + return x +} + +// https://webidl.spec.whatwg.org/#abstract-opdef-integerpart +webidl.util.IntegerPart = function (n) { + // 1. Let r be floor(abs(n)). + const r = Math.floor(Math.abs(n)) + + // 2. If n < 0, then return -1 × r. + if (n < 0) { + return -1 * r + } + + // 3. Otherwise, return r. + return r +} + +// https://webidl.spec.whatwg.org/#es-sequence +webidl.sequenceConverter = function (converter) { + return (V) => { + // 1. If Type(V) is not Object, throw a TypeError. + if (webidl.util.Type(V) !== 'Object') { + throw webidl.errors.exception({ + header: 'Sequence', + message: `Value of type ${webidl.util.Type(V)} is not an Object.` + }) + } + + // 2. Let method be ? GetMethod(V, @@iterator). + /** @type {Generator} */ + const method = V?.[Symbol.iterator]?.() + const seq = [] + + // 3. If method is undefined, throw a TypeError. + if ( + method === undefined || + typeof method.next !== 'function' + ) { + throw webidl.errors.exception({ + header: 'Sequence', + message: 'Object is not an iterator.' + }) + } + + // https://webidl.spec.whatwg.org/#create-sequence-from-iterable + while (true) { + const { done, value } = method.next() + + if (done) { + break + } + + seq.push(converter(value)) + } + + return seq + } +} + +// https://webidl.spec.whatwg.org/#es-to-record +webidl.recordConverter = function (keyConverter, valueConverter) { + return (O) => { + // 1. If Type(O) is not Object, throw a TypeError. + if (webidl.util.Type(O) !== 'Object') { + throw webidl.errors.exception({ + header: 'Record', + message: `Value of type ${webidl.util.Type(O)} is not an Object.` + }) + } + + // 2. Let result be a new empty instance of record. + const result = {} + + if (!types.isProxy(O)) { + // Object.keys only returns enumerable properties + const keys = Object.keys(O) + + for (const key of keys) { + // 1. Let typedKey be key converted to an IDL value of type K. + const typedKey = keyConverter(key) + + // 2. Let value be ? Get(O, key). + // 3. Let typedValue be value converted to an IDL value of type V. + const typedValue = valueConverter(O[key]) + + // 4. Set result[typedKey] to typedValue. + result[typedKey] = typedValue + } + + // 5. Return result. + return result + } + + // 3. Let keys be ? O.[[OwnPropertyKeys]](). + const keys = Reflect.ownKeys(O) + + // 4. For each key of keys. + for (const key of keys) { + // 1. Let desc be ? O.[[GetOwnProperty]](key). + const desc = Reflect.getOwnPropertyDescriptor(O, key) + + // 2. If desc is not undefined and desc.[[Enumerable]] is true: + if (desc?.enumerable) { + // 1. Let typedKey be key converted to an IDL value of type K. + const typedKey = keyConverter(key) + + // 2. Let value be ? Get(O, key). + // 3. Let typedValue be value converted to an IDL value of type V. + const typedValue = valueConverter(O[key]) + + // 4. Set result[typedKey] to typedValue. + result[typedKey] = typedValue + } + } + + // 5. Return result. + return result + } +} + +webidl.interfaceConverter = function (i) { + return (V, opts = {}) => { + if (opts.strict !== false && !(V instanceof i)) { + throw webidl.errors.exception({ + header: i.name, + message: `Expected ${V} to be an instance of ${i.name}.` + }) + } + + return V + } +} + +webidl.dictionaryConverter = function (converters) { + return (dictionary) => { + const type = webidl.util.Type(dictionary) + const dict = {} + + if (type === 'Null' || type === 'Undefined') { + return dict + } else if (type !== 'Object') { + throw webidl.errors.exception({ + header: 'Dictionary', + message: `Expected ${dictionary} to be one of: Null, Undefined, Object.` + }) + } + + for (const options of converters) { + const { key, defaultValue, required, converter } = options + + if (required === true) { + if (!hasOwn(dictionary, key)) { + throw webidl.errors.exception({ + header: 'Dictionary', + message: `Missing required key "${key}".` + }) + } + } + + let value = dictionary[key] + const hasDefault = hasOwn(options, 'defaultValue') + + // Only use defaultValue if value is undefined and + // a defaultValue options was provided. + if (hasDefault && value !== null) { + value = value ?? defaultValue + } + + // A key can be optional and have no default value. + // When this happens, do not perform a conversion, + // and do not assign the key a value. + if (required || hasDefault || value !== undefined) { + value = converter(value) + + if ( + options.allowedValues && + !options.allowedValues.includes(value) + ) { + throw webidl.errors.exception({ + header: 'Dictionary', + message: `${value} is not an accepted type. Expected one of ${options.allowedValues.join(', ')}.` + }) + } + + dict[key] = value + } + } + + return dict + } +} + +webidl.nullableConverter = function (converter) { + return (V) => { + if (V === null) { + return V + } + + return converter(V) + } +} + +// https://webidl.spec.whatwg.org/#es-DOMString +webidl.converters.DOMString = function (V, opts = {}) { + // 1. If V is null and the conversion is to an IDL type + // associated with the [LegacyNullToEmptyString] + // extended attribute, then return the DOMString value + // that represents the empty string. + if (V === null && opts.legacyNullToEmptyString) { + return '' + } + + // 2. Let x be ? ToString(V). + if (typeof V === 'symbol') { + throw new TypeError('Could not convert argument of type symbol to string.') + } + + // 3. Return the IDL DOMString value that represents the + // same sequence of code units as the one the + // ECMAScript String value x represents. + return String(V) +} + +// https://webidl.spec.whatwg.org/#es-ByteString +webidl.converters.ByteString = function (V) { + // 1. Let x be ? ToString(V). + // Note: DOMString converter perform ? ToString(V) + const x = webidl.converters.DOMString(V) + + // 2. If the value of any element of x is greater than + // 255, then throw a TypeError. + for (let index = 0; index < x.length; index++) { + if (x.charCodeAt(index) > 255) { + throw new TypeError( + 'Cannot convert argument to a ByteString because the character at ' + + `index ${index} has a value of ${x.charCodeAt(index)} which is greater than 255.` + ) + } + } + + // 3. Return an IDL ByteString value whose length is the + // length of x, and where the value of each element is + // the value of the corresponding element of x. + return x +} + +// https://webidl.spec.whatwg.org/#es-USVString +webidl.converters.USVString = toUSVString + +// https://webidl.spec.whatwg.org/#es-boolean +webidl.converters.boolean = function (V) { + // 1. Let x be the result of computing ToBoolean(V). + const x = Boolean(V) + + // 2. Return the IDL boolean value that is the one that represents + // the same truth value as the ECMAScript Boolean value x. + return x +} + +// https://webidl.spec.whatwg.org/#es-any +webidl.converters.any = function (V) { + return V +} + +// https://webidl.spec.whatwg.org/#es-long-long +webidl.converters['long long'] = function (V) { + // 1. Let x be ? ConvertToInt(V, 64, "signed"). + const x = webidl.util.ConvertToInt(V, 64, 'signed') + + // 2. Return the IDL long long value that represents + // the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-long-long +webidl.converters['unsigned long long'] = function (V) { + // 1. Let x be ? ConvertToInt(V, 64, "unsigned"). + const x = webidl.util.ConvertToInt(V, 64, 'unsigned') + + // 2. Return the IDL unsigned long long value that + // represents the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-long +webidl.converters['unsigned long'] = function (V) { + // 1. Let x be ? ConvertToInt(V, 32, "unsigned"). + const x = webidl.util.ConvertToInt(V, 32, 'unsigned') + + // 2. Return the IDL unsigned long value that + // represents the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-short +webidl.converters['unsigned short'] = function (V, opts) { + // 1. Let x be ? ConvertToInt(V, 16, "unsigned"). + const x = webidl.util.ConvertToInt(V, 16, 'unsigned', opts) + + // 2. Return the IDL unsigned short value that represents + // the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#idl-ArrayBuffer +webidl.converters.ArrayBuffer = function (V, opts = {}) { + // 1. If Type(V) is not Object, or V does not have an + // [[ArrayBufferData]] internal slot, then throw a + // TypeError. + // see: https://tc39.es/ecma262/#sec-properties-of-the-arraybuffer-instances + // see: https://tc39.es/ecma262/#sec-properties-of-the-sharedarraybuffer-instances + if ( + webidl.util.Type(V) !== 'Object' || + !types.isAnyArrayBuffer(V) + ) { + throw webidl.errors.conversionFailed({ + prefix: `${V}`, + argument: `${V}`, + types: ['ArrayBuffer'] + }) + } + + // 2. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V) is true, then throw a + // TypeError. + if (opts.allowShared === false && types.isSharedArrayBuffer(V)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V) is true, then throw a + // TypeError. + // Note: resizable ArrayBuffers are currently a proposal. + + // 4. Return the IDL ArrayBuffer value that is a + // reference to the same object as V. + return V +} + +webidl.converters.TypedArray = function (V, T, opts = {}) { + // 1. Let T be the IDL type V is being converted to. + + // 2. If Type(V) is not Object, or V does not have a + // [[TypedArrayName]] internal slot with a value + // equal to T’s name, then throw a TypeError. + if ( + webidl.util.Type(V) !== 'Object' || + !types.isTypedArray(V) || + V.constructor.name !== T.name + ) { + throw webidl.errors.conversionFailed({ + prefix: `${T.name}`, + argument: `${V}`, + types: [T.name] + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + if (opts.allowShared === false && types.isSharedArrayBuffer(V.buffer)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 4. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + // Note: resizable array buffers are currently a proposal + + // 5. Return the IDL value of type T that is a reference + // to the same object as V. + return V +} + +webidl.converters.DataView = function (V, opts = {}) { + // 1. If Type(V) is not Object, or V does not have a + // [[DataView]] internal slot, then throw a TypeError. + if (webidl.util.Type(V) !== 'Object' || !types.isDataView(V)) { + throw webidl.errors.exception({ + header: 'DataView', + message: 'Object is not a DataView.' + }) + } + + // 2. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V.[[ViewedArrayBuffer]]) is true, + // then throw a TypeError. + if (opts.allowShared === false && types.isSharedArrayBuffer(V.buffer)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + // Note: resizable ArrayBuffers are currently a proposal + + // 4. Return the IDL DataView value that is a reference + // to the same object as V. + return V +} + +// https://webidl.spec.whatwg.org/#BufferSource +webidl.converters.BufferSource = function (V, opts = {}) { + if (types.isAnyArrayBuffer(V)) { + return webidl.converters.ArrayBuffer(V, opts) + } + + if (types.isTypedArray(V)) { + return webidl.converters.TypedArray(V, V.constructor) + } + + if (types.isDataView(V)) { + return webidl.converters.DataView(V, opts) + } + + throw new TypeError(`Could not convert ${V} to a BufferSource.`) +} + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.ByteString +) + +webidl.converters['sequence>'] = webidl.sequenceConverter( + webidl.converters['sequence'] +) + +webidl.converters['record'] = webidl.recordConverter( + webidl.converters.ByteString, + webidl.converters.ByteString +) + +module.exports = { + webidl +} diff --git a/lib/fileapi/encoding.js b/lib/fileapi/encoding.js new file mode 100644 index 0000000..1d1d2b6 --- /dev/null +++ b/lib/fileapi/encoding.js @@ -0,0 +1,290 @@ +'use strict' + +/** + * @see https://encoding.spec.whatwg.org/#concept-encoding-get + * @param {string|undefined} label + */ +function getEncoding (label) { + if (!label) { + return 'failure' + } + + // 1. Remove any leading and trailing ASCII whitespace from label. + // 2. If label is an ASCII case-insensitive match for any of the + // labels listed in the table below, then return the + // corresponding encoding; otherwise return failure. + switch (label.trim().toLowerCase()) { + case 'unicode-1-1-utf-8': + case 'unicode11utf8': + case 'unicode20utf8': + case 'utf-8': + case 'utf8': + case 'x-unicode20utf8': + return 'UTF-8' + case '866': + case 'cp866': + case 'csibm866': + case 'ibm866': + return 'IBM866' + case 'csisolatin2': + case 'iso-8859-2': + case 'iso-ir-101': + case 'iso8859-2': + case 'iso88592': + case 'iso_8859-2': + case 'iso_8859-2:1987': + case 'l2': + case 'latin2': + return 'ISO-8859-2' + case 'csisolatin3': + case 'iso-8859-3': + case 'iso-ir-109': + case 'iso8859-3': + case 'iso88593': + case 'iso_8859-3': + case 'iso_8859-3:1988': + case 'l3': + case 'latin3': + return 'ISO-8859-3' + case 'csisolatin4': + case 'iso-8859-4': + case 'iso-ir-110': + case 'iso8859-4': + case 'iso88594': + case 'iso_8859-4': + case 'iso_8859-4:1988': + case 'l4': + case 'latin4': + return 'ISO-8859-4' + case 'csisolatincyrillic': + case 'cyrillic': + case 'iso-8859-5': + case 'iso-ir-144': + case 'iso8859-5': + case 'iso88595': + case 'iso_8859-5': + case 'iso_8859-5:1988': + return 'ISO-8859-5' + case 'arabic': + case 'asmo-708': + case 'csiso88596e': + case 'csiso88596i': + case 'csisolatinarabic': + case 'ecma-114': + case 'iso-8859-6': + case 'iso-8859-6-e': + case 'iso-8859-6-i': + case 'iso-ir-127': + case 'iso8859-6': + case 'iso88596': + case 'iso_8859-6': + case 'iso_8859-6:1987': + return 'ISO-8859-6' + case 'csisolatingreek': + case 'ecma-118': + case 'elot_928': + case 'greek': + case 'greek8': + case 'iso-8859-7': + case 'iso-ir-126': + case 'iso8859-7': + case 'iso88597': + case 'iso_8859-7': + case 'iso_8859-7:1987': + case 'sun_eu_greek': + return 'ISO-8859-7' + case 'csiso88598e': + case 'csisolatinhebrew': + case 'hebrew': + case 'iso-8859-8': + case 'iso-8859-8-e': + case 'iso-ir-138': + case 'iso8859-8': + case 'iso88598': + case 'iso_8859-8': + case 'iso_8859-8:1988': + case 'visual': + return 'ISO-8859-8' + case 'csiso88598i': + case 'iso-8859-8-i': + case 'logical': + return 'ISO-8859-8-I' + case 'csisolatin6': + case 'iso-8859-10': + case 'iso-ir-157': + case 'iso8859-10': + case 'iso885910': + case 'l6': + case 'latin6': + return 'ISO-8859-10' + case 'iso-8859-13': + case 'iso8859-13': + case 'iso885913': + return 'ISO-8859-13' + case 'iso-8859-14': + case 'iso8859-14': + case 'iso885914': + return 'ISO-8859-14' + case 'csisolatin9': + case 'iso-8859-15': + case 'iso8859-15': + case 'iso885915': + case 'iso_8859-15': + case 'l9': + return 'ISO-8859-15' + case 'iso-8859-16': + return 'ISO-8859-16' + case 'cskoi8r': + case 'koi': + case 'koi8': + case 'koi8-r': + case 'koi8_r': + return 'KOI8-R' + case 'koi8-ru': + case 'koi8-u': + return 'KOI8-U' + case 'csmacintosh': + case 'mac': + case 'macintosh': + case 'x-mac-roman': + return 'macintosh' + case 'iso-8859-11': + case 'iso8859-11': + case 'iso885911': + case 'tis-620': + case 'windows-874': + return 'windows-874' + case 'cp1250': + case 'windows-1250': + case 'x-cp1250': + return 'windows-1250' + case 'cp1251': + case 'windows-1251': + case 'x-cp1251': + return 'windows-1251' + case 'ansi_x3.4-1968': + case 'ascii': + case 'cp1252': + case 'cp819': + case 'csisolatin1': + case 'ibm819': + case 'iso-8859-1': + case 'iso-ir-100': + case 'iso8859-1': + case 'iso88591': + case 'iso_8859-1': + case 'iso_8859-1:1987': + case 'l1': + case 'latin1': + case 'us-ascii': + case 'windows-1252': + case 'x-cp1252': + return 'windows-1252' + case 'cp1253': + case 'windows-1253': + case 'x-cp1253': + return 'windows-1253' + case 'cp1254': + case 'csisolatin5': + case 'iso-8859-9': + case 'iso-ir-148': + case 'iso8859-9': + case 'iso88599': + case 'iso_8859-9': + case 'iso_8859-9:1989': + case 'l5': + case 'latin5': + case 'windows-1254': + case 'x-cp1254': + return 'windows-1254' + case 'cp1255': + case 'windows-1255': + case 'x-cp1255': + return 'windows-1255' + case 'cp1256': + case 'windows-1256': + case 'x-cp1256': + return 'windows-1256' + case 'cp1257': + case 'windows-1257': + case 'x-cp1257': + return 'windows-1257' + case 'cp1258': + case 'windows-1258': + case 'x-cp1258': + return 'windows-1258' + case 'x-mac-cyrillic': + case 'x-mac-ukrainian': + return 'x-mac-cyrillic' + case 'chinese': + case 'csgb2312': + case 'csiso58gb231280': + case 'gb2312': + case 'gb_2312': + case 'gb_2312-80': + case 'gbk': + case 'iso-ir-58': + case 'x-gbk': + return 'GBK' + case 'gb18030': + return 'gb18030' + case 'big5': + case 'big5-hkscs': + case 'cn-big5': + case 'csbig5': + case 'x-x-big5': + return 'Big5' + case 'cseucpkdfmtjapanese': + case 'euc-jp': + case 'x-euc-jp': + return 'EUC-JP' + case 'csiso2022jp': + case 'iso-2022-jp': + return 'ISO-2022-JP' + case 'csshiftjis': + case 'ms932': + case 'ms_kanji': + case 'shift-jis': + case 'shift_jis': + case 'sjis': + case 'windows-31j': + case 'x-sjis': + return 'Shift_JIS' + case 'cseuckr': + case 'csksc56011987': + case 'euc-kr': + case 'iso-ir-149': + case 'korean': + case 'ks_c_5601-1987': + case 'ks_c_5601-1989': + case 'ksc5601': + case 'ksc_5601': + case 'windows-949': + return 'EUC-KR' + case 'csiso2022kr': + case 'hz-gb-2312': + case 'iso-2022-cn': + case 'iso-2022-cn-ext': + case 'iso-2022-kr': + case 'replacement': + return 'replacement' + case 'unicodefffe': + case 'utf-16be': + return 'UTF-16BE' + case 'csunicode': + case 'iso-10646-ucs-2': + case 'ucs-2': + case 'unicode': + case 'unicodefeff': + case 'utf-16': + case 'utf-16le': + return 'UTF-16LE' + case 'x-user-defined': + return 'x-user-defined' + default: return 'failure' + } +} + +module.exports = { + getEncoding +} diff --git a/lib/fileapi/filereader.js b/lib/fileapi/filereader.js new file mode 100644 index 0000000..cd36a22 --- /dev/null +++ b/lib/fileapi/filereader.js @@ -0,0 +1,344 @@ +'use strict' + +const { + staticPropertyDescriptors, + readOperation, + fireAProgressEvent +} = require('./util') +const { + kState, + kError, + kResult, + kEvents, + kAborted +} = require('./symbols') +const { webidl } = require('../fetch/webidl') +const { kEnumerableProperty } = require('../core/util') + +class FileReader extends EventTarget { + constructor () { + super() + + this[kState] = 'empty' + this[kResult] = null + this[kError] = null + this[kEvents] = { + loadend: null, + error: null, + abort: null, + load: null, + progress: null, + loadstart: null + } + } + + /** + * @see https://w3c.github.io/FileAPI/#dfn-readAsArrayBuffer + * @param {import('buffer').Blob} blob + */ + readAsArrayBuffer (blob) { + webidl.brandCheck(this, FileReader) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FileReader.readAsArrayBuffer' }) + + blob = webidl.converters.Blob(blob, { strict: false }) + + // The readAsArrayBuffer(blob) method, when invoked, + // must initiate a read operation for blob with ArrayBuffer. + readOperation(this, blob, 'ArrayBuffer') + } + + /** + * @see https://w3c.github.io/FileAPI/#readAsBinaryString + * @param {import('buffer').Blob} blob + */ + readAsBinaryString (blob) { + webidl.brandCheck(this, FileReader) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FileReader.readAsBinaryString' }) + + blob = webidl.converters.Blob(blob, { strict: false }) + + // The readAsBinaryString(blob) method, when invoked, + // must initiate a read operation for blob with BinaryString. + readOperation(this, blob, 'BinaryString') + } + + /** + * @see https://w3c.github.io/FileAPI/#readAsDataText + * @param {import('buffer').Blob} blob + * @param {string?} encoding + */ + readAsText (blob, encoding = undefined) { + webidl.brandCheck(this, FileReader) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FileReader.readAsText' }) + + blob = webidl.converters.Blob(blob, { strict: false }) + + if (encoding !== undefined) { + encoding = webidl.converters.DOMString(encoding) + } + + // The readAsText(blob, encoding) method, when invoked, + // must initiate a read operation for blob with Text and encoding. + readOperation(this, blob, 'Text', encoding) + } + + /** + * @see https://w3c.github.io/FileAPI/#dfn-readAsDataURL + * @param {import('buffer').Blob} blob + */ + readAsDataURL (blob) { + webidl.brandCheck(this, FileReader) + + webidl.argumentLengthCheck(arguments, 1, { header: 'FileReader.readAsDataURL' }) + + blob = webidl.converters.Blob(blob, { strict: false }) + + // The readAsDataURL(blob) method, when invoked, must + // initiate a read operation for blob with DataURL. + readOperation(this, blob, 'DataURL') + } + + /** + * @see https://w3c.github.io/FileAPI/#dfn-abort + */ + abort () { + // 1. If this's state is "empty" or if this's state is + // "done" set this's result to null and terminate + // this algorithm. + if (this[kState] === 'empty' || this[kState] === 'done') { + this[kResult] = null + return + } + + // 2. If this's state is "loading" set this's state to + // "done" and set this's result to null. + if (this[kState] === 'loading') { + this[kState] = 'done' + this[kResult] = null + } + + // 3. If there are any tasks from this on the file reading + // task source in an affiliated task queue, then remove + // those tasks from that task queue. + this[kAborted] = true + + // 4. Terminate the algorithm for the read method being processed. + // TODO + + // 5. Fire a progress event called abort at this. + fireAProgressEvent('abort', this) + + // 6. If this's state is not "loading", fire a progress + // event called loadend at this. + if (this[kState] !== 'loading') { + fireAProgressEvent('loadend', this) + } + } + + /** + * @see https://w3c.github.io/FileAPI/#dom-filereader-readystate + */ + get readyState () { + webidl.brandCheck(this, FileReader) + + switch (this[kState]) { + case 'empty': return this.EMPTY + case 'loading': return this.LOADING + case 'done': return this.DONE + } + } + + /** + * @see https://w3c.github.io/FileAPI/#dom-filereader-result + */ + get result () { + webidl.brandCheck(this, FileReader) + + // The result attribute’s getter, when invoked, must return + // this's result. + return this[kResult] + } + + /** + * @see https://w3c.github.io/FileAPI/#dom-filereader-error + */ + get error () { + webidl.brandCheck(this, FileReader) + + // The error attribute’s getter, when invoked, must return + // this's error. + return this[kError] + } + + get onloadend () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].loadend + } + + set onloadend (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].loadend) { + this.removeEventListener('loadend', this[kEvents].loadend) + } + + if (typeof fn === 'function') { + this[kEvents].loadend = fn + this.addEventListener('loadend', fn) + } else { + this[kEvents].loadend = null + } + } + + get onerror () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].error + } + + set onerror (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].error) { + this.removeEventListener('error', this[kEvents].error) + } + + if (typeof fn === 'function') { + this[kEvents].error = fn + this.addEventListener('error', fn) + } else { + this[kEvents].error = null + } + } + + get onloadstart () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].loadstart + } + + set onloadstart (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].loadstart) { + this.removeEventListener('loadstart', this[kEvents].loadstart) + } + + if (typeof fn === 'function') { + this[kEvents].loadstart = fn + this.addEventListener('loadstart', fn) + } else { + this[kEvents].loadstart = null + } + } + + get onprogress () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].progress + } + + set onprogress (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].progress) { + this.removeEventListener('progress', this[kEvents].progress) + } + + if (typeof fn === 'function') { + this[kEvents].progress = fn + this.addEventListener('progress', fn) + } else { + this[kEvents].progress = null + } + } + + get onload () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].load + } + + set onload (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].load) { + this.removeEventListener('load', this[kEvents].load) + } + + if (typeof fn === 'function') { + this[kEvents].load = fn + this.addEventListener('load', fn) + } else { + this[kEvents].load = null + } + } + + get onabort () { + webidl.brandCheck(this, FileReader) + + return this[kEvents].abort + } + + set onabort (fn) { + webidl.brandCheck(this, FileReader) + + if (this[kEvents].abort) { + this.removeEventListener('abort', this[kEvents].abort) + } + + if (typeof fn === 'function') { + this[kEvents].abort = fn + this.addEventListener('abort', fn) + } else { + this[kEvents].abort = null + } + } +} + +// https://w3c.github.io/FileAPI/#dom-filereader-empty +FileReader.EMPTY = FileReader.prototype.EMPTY = 0 +// https://w3c.github.io/FileAPI/#dom-filereader-loading +FileReader.LOADING = FileReader.prototype.LOADING = 1 +// https://w3c.github.io/FileAPI/#dom-filereader-done +FileReader.DONE = FileReader.prototype.DONE = 2 + +Object.defineProperties(FileReader.prototype, { + EMPTY: staticPropertyDescriptors, + LOADING: staticPropertyDescriptors, + DONE: staticPropertyDescriptors, + readAsArrayBuffer: kEnumerableProperty, + readAsBinaryString: kEnumerableProperty, + readAsText: kEnumerableProperty, + readAsDataURL: kEnumerableProperty, + abort: kEnumerableProperty, + readyState: kEnumerableProperty, + result: kEnumerableProperty, + error: kEnumerableProperty, + onloadstart: kEnumerableProperty, + onprogress: kEnumerableProperty, + onload: kEnumerableProperty, + onabort: kEnumerableProperty, + onerror: kEnumerableProperty, + onloadend: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'FileReader', + writable: false, + enumerable: false, + configurable: true + } +}) + +Object.defineProperties(FileReader, { + EMPTY: staticPropertyDescriptors, + LOADING: staticPropertyDescriptors, + DONE: staticPropertyDescriptors +}) + +module.exports = { + FileReader +} diff --git a/lib/fileapi/progressevent.js b/lib/fileapi/progressevent.js new file mode 100644 index 0000000..778cf22 --- /dev/null +++ b/lib/fileapi/progressevent.js @@ -0,0 +1,78 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') + +const kState = Symbol('ProgressEvent state') + +/** + * @see https://xhr.spec.whatwg.org/#progressevent + */ +class ProgressEvent extends Event { + constructor (type, eventInitDict = {}) { + type = webidl.converters.DOMString(type) + eventInitDict = webidl.converters.ProgressEventInit(eventInitDict ?? {}) + + super(type, eventInitDict) + + this[kState] = { + lengthComputable: eventInitDict.lengthComputable, + loaded: eventInitDict.loaded, + total: eventInitDict.total + } + } + + get lengthComputable () { + webidl.brandCheck(this, ProgressEvent) + + return this[kState].lengthComputable + } + + get loaded () { + webidl.brandCheck(this, ProgressEvent) + + return this[kState].loaded + } + + get total () { + webidl.brandCheck(this, ProgressEvent) + + return this[kState].total + } +} + +webidl.converters.ProgressEventInit = webidl.dictionaryConverter([ + { + key: 'lengthComputable', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'loaded', + converter: webidl.converters['unsigned long long'], + defaultValue: 0 + }, + { + key: 'total', + converter: webidl.converters['unsigned long long'], + defaultValue: 0 + }, + { + key: 'bubbles', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'cancelable', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'composed', + converter: webidl.converters.boolean, + defaultValue: false + } +]) + +module.exports = { + ProgressEvent +} diff --git a/lib/fileapi/symbols.js b/lib/fileapi/symbols.js new file mode 100644 index 0000000..dd11746 --- /dev/null +++ b/lib/fileapi/symbols.js @@ -0,0 +1,10 @@ +'use strict' + +module.exports = { + kState: Symbol('FileReader state'), + kResult: Symbol('FileReader result'), + kError: Symbol('FileReader error'), + kLastProgressEventFired: Symbol('FileReader last progress event fired timestamp'), + kEvents: Symbol('FileReader events'), + kAborted: Symbol('FileReader aborted') +} diff --git a/lib/fileapi/util.js b/lib/fileapi/util.js new file mode 100644 index 0000000..1d10899 --- /dev/null +++ b/lib/fileapi/util.js @@ -0,0 +1,392 @@ +'use strict' + +const { + kState, + kError, + kResult, + kAborted, + kLastProgressEventFired +} = require('./symbols') +const { ProgressEvent } = require('./progressevent') +const { getEncoding } = require('./encoding') +const { DOMException } = require('../fetch/constants') +const { serializeAMimeType, parseMIMEType } = require('../fetch/dataURL') +const { types } = require('util') +const { StringDecoder } = require('string_decoder') +const { btoa } = require('buffer') + +/** @type {PropertyDescriptor} */ +const staticPropertyDescriptors = { + enumerable: true, + writable: false, + configurable: false +} + +/** + * @see https://w3c.github.io/FileAPI/#readOperation + * @param {import('./filereader').FileReader} fr + * @param {import('buffer').Blob} blob + * @param {string} type + * @param {string?} encodingName + */ +function readOperation (fr, blob, type, encodingName) { + // 1. If fr’s state is "loading", throw an InvalidStateError + // DOMException. + if (fr[kState] === 'loading') { + throw new DOMException('Invalid state', 'InvalidStateError') + } + + // 2. Set fr’s state to "loading". + fr[kState] = 'loading' + + // 3. Set fr’s result to null. + fr[kResult] = null + + // 4. Set fr’s error to null. + fr[kError] = null + + // 5. Let stream be the result of calling get stream on blob. + /** @type {import('stream/web').ReadableStream} */ + const stream = blob.stream() + + // 6. Let reader be the result of getting a reader from stream. + const reader = stream.getReader() + + // 7. Let bytes be an empty byte sequence. + /** @type {Uint8Array[]} */ + const bytes = [] + + // 8. Let chunkPromise be the result of reading a chunk from + // stream with reader. + let chunkPromise = reader.read() + + // 9. Let isFirstChunk be true. + let isFirstChunk = true + + // 10. In parallel, while true: + // Note: "In parallel" just means non-blocking + // Note 2: readOperation itself cannot be async as double + // reading the body would then reject the promise, instead + // of throwing an error. + ;(async () => { + while (!fr[kAborted]) { + // 1. Wait for chunkPromise to be fulfilled or rejected. + try { + const { done, value } = await chunkPromise + + // 2. If chunkPromise is fulfilled, and isFirstChunk is + // true, queue a task to fire a progress event called + // loadstart at fr. + if (isFirstChunk && !fr[kAborted]) { + queueMicrotask(() => { + fireAProgressEvent('loadstart', fr) + }) + } + + // 3. Set isFirstChunk to false. + isFirstChunk = false + + // 4. If chunkPromise is fulfilled with an object whose + // done property is false and whose value property is + // a Uint8Array object, run these steps: + if (!done && types.isUint8Array(value)) { + // 1. Let bs be the byte sequence represented by the + // Uint8Array object. + + // 2. Append bs to bytes. + bytes.push(value) + + // 3. If roughly 50ms have passed since these steps + // were last invoked, queue a task to fire a + // progress event called progress at fr. + if ( + ( + fr[kLastProgressEventFired] === undefined || + Date.now() - fr[kLastProgressEventFired] >= 50 + ) && + !fr[kAborted] + ) { + fr[kLastProgressEventFired] = Date.now() + queueMicrotask(() => { + fireAProgressEvent('progress', fr) + }) + } + + // 4. Set chunkPromise to the result of reading a + // chunk from stream with reader. + chunkPromise = reader.read() + } else if (done) { + // 5. Otherwise, if chunkPromise is fulfilled with an + // object whose done property is true, queue a task + // to run the following steps and abort this algorithm: + queueMicrotask(() => { + // 1. Set fr’s state to "done". + fr[kState] = 'done' + + // 2. Let result be the result of package data given + // bytes, type, blob’s type, and encodingName. + try { + const result = packageData(bytes, type, blob.type, encodingName) + + // 4. Else: + + if (fr[kAborted]) { + return + } + + // 1. Set fr’s result to result. + fr[kResult] = result + + // 2. Fire a progress event called load at the fr. + fireAProgressEvent('load', fr) + } catch (error) { + // 3. If package data threw an exception error: + + // 1. Set fr’s error to error. + fr[kError] = error + + // 2. Fire a progress event called error at fr. + fireAProgressEvent('error', fr) + } + + // 5. If fr’s state is not "loading", fire a progress + // event called loadend at the fr. + if (fr[kState] !== 'loading') { + fireAProgressEvent('loadend', fr) + } + }) + + break + } + } catch (error) { + if (fr[kAborted]) { + return + } + + // 6. Otherwise, if chunkPromise is rejected with an + // error error, queue a task to run the following + // steps and abort this algorithm: + queueMicrotask(() => { + // 1. Set fr’s state to "done". + fr[kState] = 'done' + + // 2. Set fr’s error to error. + fr[kError] = error + + // 3. Fire a progress event called error at fr. + fireAProgressEvent('error', fr) + + // 4. If fr’s state is not "loading", fire a progress + // event called loadend at fr. + if (fr[kState] !== 'loading') { + fireAProgressEvent('loadend', fr) + } + }) + + break + } + } + })() +} + +/** + * @see https://w3c.github.io/FileAPI/#fire-a-progress-event + * @see https://dom.spec.whatwg.org/#concept-event-fire + * @param {string} e The name of the event + * @param {import('./filereader').FileReader} reader + */ +function fireAProgressEvent (e, reader) { + // The progress event e does not bubble. e.bubbles must be false + // The progress event e is NOT cancelable. e.cancelable must be false + const event = new ProgressEvent(e, { + bubbles: false, + cancelable: false + }) + + reader.dispatchEvent(event) +} + +/** + * @see https://w3c.github.io/FileAPI/#blob-package-data + * @param {Uint8Array[]} bytes + * @param {string} type + * @param {string?} mimeType + * @param {string?} encodingName + */ +function packageData (bytes, type, mimeType, encodingName) { + // 1. A Blob has an associated package data algorithm, given + // bytes, a type, a optional mimeType, and a optional + // encodingName, which switches on type and runs the + // associated steps: + + switch (type) { + case 'DataURL': { + // 1. Return bytes as a DataURL [RFC2397] subject to + // the considerations below: + // * Use mimeType as part of the Data URL if it is + // available in keeping with the Data URL + // specification [RFC2397]. + // * If mimeType is not available return a Data URL + // without a media-type. [RFC2397]. + + // https://datatracker.ietf.org/doc/html/rfc2397#section-3 + // dataurl := "data:" [ mediatype ] [ ";base64" ] "," data + // mediatype := [ type "/" subtype ] *( ";" parameter ) + // data := *urlchar + // parameter := attribute "=" value + let dataURL = 'data:' + + const parsed = parseMIMEType(mimeType || 'application/octet-stream') + + if (parsed !== 'failure') { + dataURL += serializeAMimeType(parsed) + } + + dataURL += ';base64,' + + const decoder = new StringDecoder('latin1') + + for (const chunk of bytes) { + dataURL += btoa(decoder.write(chunk)) + } + + dataURL += btoa(decoder.end()) + + return dataURL + } + case 'Text': { + // 1. Let encoding be failure + let encoding = 'failure' + + // 2. If the encodingName is present, set encoding to the + // result of getting an encoding from encodingName. + if (encodingName) { + encoding = getEncoding(encodingName) + } + + // 3. If encoding is failure, and mimeType is present: + if (encoding === 'failure' && mimeType) { + // 1. Let type be the result of parse a MIME type + // given mimeType. + const type = parseMIMEType(mimeType) + + // 2. If type is not failure, set encoding to the result + // of getting an encoding from type’s parameters["charset"]. + if (type !== 'failure') { + encoding = getEncoding(type.parameters.get('charset')) + } + } + + // 4. If encoding is failure, then set encoding to UTF-8. + if (encoding === 'failure') { + encoding = 'UTF-8' + } + + // 5. Decode bytes using fallback encoding encoding, and + // return the result. + return decode(bytes, encoding) + } + case 'ArrayBuffer': { + // Return a new ArrayBuffer whose contents are bytes. + const sequence = combineByteSequences(bytes) + + return sequence.buffer + } + case 'BinaryString': { + // Return bytes as a binary string, in which every byte + // is represented by a code unit of equal value [0..255]. + let binaryString = '' + + const decoder = new StringDecoder('latin1') + + for (const chunk of bytes) { + binaryString += decoder.write(chunk) + } + + binaryString += decoder.end() + + return binaryString + } + } +} + +/** + * @see https://encoding.spec.whatwg.org/#decode + * @param {Uint8Array[]} ioQueue + * @param {string} encoding + */ +function decode (ioQueue, encoding) { + const bytes = combineByteSequences(ioQueue) + + // 1. Let BOMEncoding be the result of BOM sniffing ioQueue. + const BOMEncoding = BOMSniffing(bytes) + + let slice = 0 + + // 2. If BOMEncoding is non-null: + if (BOMEncoding !== null) { + // 1. Set encoding to BOMEncoding. + encoding = BOMEncoding + + // 2. Read three bytes from ioQueue, if BOMEncoding is + // UTF-8; otherwise read two bytes. + // (Do nothing with those bytes.) + slice = BOMEncoding === 'UTF-8' ? 3 : 2 + } + + // 3. Process a queue with an instance of encoding’s + // decoder, ioQueue, output, and "replacement". + + // 4. Return output. + + const sliced = bytes.slice(slice) + return new TextDecoder(encoding).decode(sliced) +} + +/** + * @see https://encoding.spec.whatwg.org/#bom-sniff + * @param {Uint8Array} ioQueue + */ +function BOMSniffing (ioQueue) { + // 1. Let BOM be the result of peeking 3 bytes from ioQueue, + // converted to a byte sequence. + const [a, b, c] = ioQueue + + // 2. For each of the rows in the table below, starting with + // the first one and going down, if BOM starts with the + // bytes given in the first column, then return the + // encoding given in the cell in the second column of that + // row. Otherwise, return null. + if (a === 0xEF && b === 0xBB && c === 0xBF) { + return 'UTF-8' + } else if (a === 0xFE && b === 0xFF) { + return 'UTF-16BE' + } else if (a === 0xFF && b === 0xFE) { + return 'UTF-16LE' + } + + return null +} + +/** + * @param {Uint8Array[]} sequences + */ +function combineByteSequences (sequences) { + const size = sequences.reduce((a, b) => { + return a + b.byteLength + }, 0) + + let offset = 0 + + return sequences.reduce((a, b) => { + a.set(b, offset) + offset += b.byteLength + return a + }, new Uint8Array(size)) +} + +module.exports = { + staticPropertyDescriptors, + readOperation, + fireAProgressEvent +} diff --git a/lib/global.js b/lib/global.js new file mode 100644 index 0000000..18bfd73 --- /dev/null +++ b/lib/global.js @@ -0,0 +1,32 @@ +'use strict' + +// We include a version number for the Dispatcher API. In case of breaking changes, +// this version number must be increased to avoid conflicts. +const globalDispatcher = Symbol.for('undici.globalDispatcher.1') +const { InvalidArgumentError } = require('./core/errors') +const Agent = require('./agent') + +if (getGlobalDispatcher() === undefined) { + setGlobalDispatcher(new Agent()) +} + +function setGlobalDispatcher (agent) { + if (!agent || typeof agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument agent must implement Agent') + } + Object.defineProperty(globalThis, globalDispatcher, { + value: agent, + writable: true, + enumerable: false, + configurable: false + }) +} + +function getGlobalDispatcher () { + return globalThis[globalDispatcher] +} + +module.exports = { + setGlobalDispatcher, + getGlobalDispatcher +} diff --git a/lib/handler/DecoratorHandler.js b/lib/handler/DecoratorHandler.js new file mode 100644 index 0000000..9d70a76 --- /dev/null +++ b/lib/handler/DecoratorHandler.js @@ -0,0 +1,35 @@ +'use strict' + +module.exports = class DecoratorHandler { + constructor (handler) { + this.handler = handler + } + + onConnect (...args) { + return this.handler.onConnect(...args) + } + + onError (...args) { + return this.handler.onError(...args) + } + + onUpgrade (...args) { + return this.handler.onUpgrade(...args) + } + + onHeaders (...args) { + return this.handler.onHeaders(...args) + } + + onData (...args) { + return this.handler.onData(...args) + } + + onComplete (...args) { + return this.handler.onComplete(...args) + } + + onBodySent (...args) { + return this.handler.onBodySent(...args) + } +} diff --git a/lib/handler/RedirectHandler.js b/lib/handler/RedirectHandler.js new file mode 100644 index 0000000..933725f --- /dev/null +++ b/lib/handler/RedirectHandler.js @@ -0,0 +1,221 @@ +'use strict' + +const util = require('../core/util') +const { kBodyUsed } = require('../core/symbols') +const assert = require('assert') +const { InvalidArgumentError } = require('../core/errors') +const EE = require('events') + +const redirectableStatusCodes = [300, 301, 302, 303, 307, 308] + +const kBody = Symbol('body') + +class BodyAsyncIterable { + constructor (body) { + this[kBody] = body + this[kBodyUsed] = false + } + + async * [Symbol.asyncIterator] () { + assert(!this[kBodyUsed], 'disturbed') + this[kBodyUsed] = true + yield * this[kBody] + } +} + +class RedirectHandler { + constructor (dispatch, maxRedirections, opts, handler) { + if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + util.validateHandler(handler, opts.method, opts.upgrade) + + this.dispatch = dispatch + this.location = null + this.abort = null + this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy + this.maxRedirections = maxRedirections + this.handler = handler + this.history = [] + + if (util.isStream(this.opts.body)) { + // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp + // so that it can be dispatched again? + // TODO (fix): Do we need 100-expect support to provide a way to do this properly? + if (util.bodyLength(this.opts.body) === 0) { + this.opts.body + .on('data', function () { + assert(false) + }) + } + + if (typeof this.opts.body.readableDidRead !== 'boolean') { + this.opts.body[kBodyUsed] = false + EE.prototype.on.call(this.opts.body, 'data', function () { + this[kBodyUsed] = true + }) + } + } else if (this.opts.body && typeof this.opts.body.pipeTo === 'function') { + // TODO (fix): We can't access ReadableStream internal state + // to determine whether or not it has been disturbed. This is just + // a workaround. + this.opts.body = new BodyAsyncIterable(this.opts.body) + } else if ( + this.opts.body && + typeof this.opts.body !== 'string' && + !ArrayBuffer.isView(this.opts.body) && + util.isIterable(this.opts.body) + ) { + // TODO: Should we allow re-using iterable if !this.opts.idempotent + // or through some other flag? + this.opts.body = new BodyAsyncIterable(this.opts.body) + } + } + + onConnect (abort) { + this.abort = abort + this.handler.onConnect(abort, { history: this.history }) + } + + onUpgrade (statusCode, headers, socket) { + this.handler.onUpgrade(statusCode, headers, socket) + } + + onError (error) { + this.handler.onError(error) + } + + onHeaders (statusCode, headers, resume, statusText) { + this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) + ? null + : parseLocation(statusCode, headers) + + if (this.opts.origin) { + this.history.push(new URL(this.opts.path, this.opts.origin)) + } + + if (!this.location) { + return this.handler.onHeaders(statusCode, headers, resume, statusText) + } + + const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))) + const path = search ? `${pathname}${search}` : pathname + + // Remove headers referring to the original URL. + // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers. + // https://tools.ietf.org/html/rfc7231#section-6.4 + this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin) + this.opts.path = path + this.opts.origin = origin + this.opts.maxRedirections = 0 + this.opts.query = null + + // https://tools.ietf.org/html/rfc7231#section-6.4.4 + // In case of HTTP 303, always replace method to be either HEAD or GET + if (statusCode === 303 && this.opts.method !== 'HEAD') { + this.opts.method = 'GET' + this.opts.body = null + } + } + + onData (chunk) { + if (this.location) { + /* + https://tools.ietf.org/html/rfc7231#section-6.4 + + TLDR: undici always ignores 3xx response bodies. + + Redirection is used to serve the requested resource from another URL, so it is assumes that + no body is generated (and thus can be ignored). Even though generating a body is not prohibited. + + For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually + (which means it's optional and not mandated) contain just an hyperlink to the value of + the Location response header, so the body can be ignored safely. + + For status 300, which is "Multiple Choices", the spec mentions both generating a Location + response header AND a response body with the other possible location to follow. + Since the spec explicitily chooses not to specify a format for such body and leave it to + servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it. + */ + } else { + return this.handler.onData(chunk) + } + } + + onComplete (trailers) { + if (this.location) { + /* + https://tools.ietf.org/html/rfc7231#section-6.4 + + TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections + and neither are useful if present. + + See comment on onData method above for more detailed informations. + */ + + this.location = null + this.abort = null + + this.dispatch(this.opts, this) + } else { + this.handler.onComplete(trailers) + } + } + + onBodySent (chunk) { + if (this.handler.onBodySent) { + this.handler.onBodySent(chunk) + } + } +} + +function parseLocation (statusCode, headers) { + if (redirectableStatusCodes.indexOf(statusCode) === -1) { + return null + } + + for (let i = 0; i < headers.length; i += 2) { + if (headers[i].toString().toLowerCase() === 'location') { + return headers[i + 1] + } + } +} + +// https://tools.ietf.org/html/rfc7231#section-6.4.4 +function shouldRemoveHeader (header, removeContent, unknownOrigin) { + if (header.length === 4) { + return util.headerNameToString(header) === 'host' + } + if (removeContent && util.headerNameToString(header).startsWith('content-')) { + return true + } + if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) { + const name = util.headerNameToString(header) + return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization' + } + return false +} + +// https://tools.ietf.org/html/rfc7231#section-6.4 +function cleanRequestHeaders (headers, removeContent, unknownOrigin) { + const ret = [] + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) { + ret.push(headers[i], headers[i + 1]) + } + } + } else if (headers && typeof headers === 'object') { + for (const key of Object.keys(headers)) { + if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) { + ret.push(key, headers[key]) + } + } + } else { + assert(headers == null, 'headers must be an object or an array') + } + return ret +} + +module.exports = RedirectHandler diff --git a/lib/handler/RetryHandler.js b/lib/handler/RetryHandler.js new file mode 100644 index 0000000..3710447 --- /dev/null +++ b/lib/handler/RetryHandler.js @@ -0,0 +1,336 @@ +const assert = require('assert') + +const { kRetryHandlerDefaultRetry } = require('../core/symbols') +const { RequestRetryError } = require('../core/errors') +const { isDisturbed, parseHeaders, parseRangeHeader } = require('../core/util') + +function calculateRetryAfterHeader (retryAfter) { + const current = Date.now() + const diff = new Date(retryAfter).getTime() - current + + return diff +} + +class RetryHandler { + constructor (opts, handlers) { + const { retryOptions, ...dispatchOpts } = opts + const { + // Retry scoped + retry: retryFn, + maxRetries, + maxTimeout, + minTimeout, + timeoutFactor, + // Response scoped + methods, + errorCodes, + retryAfter, + statusCodes + } = retryOptions ?? {} + + this.dispatch = handlers.dispatch + this.handler = handlers.handler + this.opts = dispatchOpts + this.abort = null + this.aborted = false + this.retryOpts = { + retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry], + retryAfter: retryAfter ?? true, + maxTimeout: maxTimeout ?? 30 * 1000, // 30s, + timeout: minTimeout ?? 500, // .5s + timeoutFactor: timeoutFactor ?? 2, + maxRetries: maxRetries ?? 5, + // What errors we should retry + methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'], + // Indicates which errors to retry + statusCodes: statusCodes ?? [500, 502, 503, 504, 429], + // List of errors to retry + errorCodes: errorCodes ?? [ + 'ECONNRESET', + 'ECONNREFUSED', + 'ENOTFOUND', + 'ENETDOWN', + 'ENETUNREACH', + 'EHOSTDOWN', + 'EHOSTUNREACH', + 'EPIPE' + ] + } + + this.retryCount = 0 + this.start = 0 + this.end = null + this.etag = null + this.resume = null + + // Handle possible onConnect duplication + this.handler.onConnect(reason => { + this.aborted = true + if (this.abort) { + this.abort(reason) + } else { + this.reason = reason + } + }) + } + + onRequestSent () { + if (this.handler.onRequestSent) { + this.handler.onRequestSent() + } + } + + onUpgrade (statusCode, headers, socket) { + if (this.handler.onUpgrade) { + this.handler.onUpgrade(statusCode, headers, socket) + } + } + + onConnect (abort) { + if (this.aborted) { + abort(this.reason) + } else { + this.abort = abort + } + } + + onBodySent (chunk) { + if (this.handler.onBodySent) return this.handler.onBodySent(chunk) + } + + static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) { + const { statusCode, code, headers } = err + const { method, retryOptions } = opts + const { + maxRetries, + timeout, + maxTimeout, + timeoutFactor, + statusCodes, + errorCodes, + methods + } = retryOptions + let { counter, currentTimeout } = state + + currentTimeout = + currentTimeout != null && currentTimeout > 0 ? currentTimeout : timeout + + // Any code that is not a Undici's originated and allowed to retry + if ( + code && + code !== 'UND_ERR_REQ_RETRY' && + code !== 'UND_ERR_SOCKET' && + !errorCodes.includes(code) + ) { + cb(err) + return + } + + // If a set of method are provided and the current method is not in the list + if (Array.isArray(methods) && !methods.includes(method)) { + cb(err) + return + } + + // If a set of status code are provided and the current status code is not in the list + if ( + statusCode != null && + Array.isArray(statusCodes) && + !statusCodes.includes(statusCode) + ) { + cb(err) + return + } + + // If we reached the max number of retries + if (counter > maxRetries) { + cb(err) + return + } + + let retryAfterHeader = headers != null && headers['retry-after'] + if (retryAfterHeader) { + retryAfterHeader = Number(retryAfterHeader) + retryAfterHeader = isNaN(retryAfterHeader) + ? calculateRetryAfterHeader(retryAfterHeader) + : retryAfterHeader * 1e3 // Retry-After is in seconds + } + + const retryTimeout = + retryAfterHeader > 0 + ? Math.min(retryAfterHeader, maxTimeout) + : Math.min(currentTimeout * timeoutFactor ** counter, maxTimeout) + + state.currentTimeout = retryTimeout + + setTimeout(() => cb(null), retryTimeout) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + const headers = parseHeaders(rawHeaders) + + this.retryCount += 1 + + if (statusCode >= 300) { + this.abort( + new RequestRetryError('Request failed', statusCode, { + headers, + count: this.retryCount + }) + ) + return false + } + + // Checkpoint for resume from where we left it + if (this.resume != null) { + this.resume = null + + if (statusCode !== 206) { + return true + } + + const contentRange = parseRangeHeader(headers['content-range']) + // If no content range + if (!contentRange) { + this.abort( + new RequestRetryError('Content-Range mismatch', statusCode, { + headers, + count: this.retryCount + }) + ) + return false + } + + // Let's start with a weak etag check + if (this.etag != null && this.etag !== headers.etag) { + this.abort( + new RequestRetryError('ETag mismatch', statusCode, { + headers, + count: this.retryCount + }) + ) + return false + } + + const { start, size, end = size } = contentRange + + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') + + this.resume = resume + return true + } + + if (this.end == null) { + if (statusCode === 206) { + // First time we receive 206 + const range = parseRangeHeader(headers['content-range']) + + if (range == null) { + return this.handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage + ) + } + + const { start, size, end = size } = range + + assert( + start != null && Number.isFinite(start) && this.start !== start, + 'content-range mismatch' + ) + assert(Number.isFinite(start)) + assert( + end != null && Number.isFinite(end) && this.end !== end, + 'invalid content-length' + ) + + this.start = start + this.end = end + } + + // We make our best to checkpoint the body for further range headers + if (this.end == null) { + const contentLength = headers['content-length'] + this.end = contentLength != null ? Number(contentLength) : null + } + + assert(Number.isFinite(this.start)) + assert( + this.end == null || Number.isFinite(this.end), + 'invalid content-length' + ) + + this.resume = resume + this.etag = headers.etag != null ? headers.etag : null + + return this.handler.onHeaders( + statusCode, + rawHeaders, + resume, + statusMessage + ) + } + + const err = new RequestRetryError('Request failed', statusCode, { + headers, + count: this.retryCount + }) + + this.abort(err) + + return false + } + + onData (chunk) { + this.start += chunk.length + + return this.handler.onData(chunk) + } + + onComplete (rawTrailers) { + this.retryCount = 0 + return this.handler.onComplete(rawTrailers) + } + + onError (err) { + if (this.aborted || isDisturbed(this.opts.body)) { + return this.handler.onError(err) + } + + this.retryOpts.retry( + err, + { + state: { counter: this.retryCount++, currentTimeout: this.retryAfter }, + opts: { retryOptions: this.retryOpts, ...this.opts } + }, + onRetry.bind(this) + ) + + function onRetry (err) { + if (err != null || this.aborted || isDisturbed(this.opts.body)) { + return this.handler.onError(err) + } + + if (this.start !== 0) { + this.opts = { + ...this.opts, + headers: { + ...this.opts.headers, + range: `bytes=${this.start}-${this.end ?? ''}` + } + } + } + + try { + this.dispatch(this.opts, this) + } catch (err) { + this.handler.onError(err) + } + } + } +} + +module.exports = RetryHandler diff --git a/lib/interceptor/redirectInterceptor.js b/lib/interceptor/redirectInterceptor.js new file mode 100644 index 0000000..7cc035e --- /dev/null +++ b/lib/interceptor/redirectInterceptor.js @@ -0,0 +1,21 @@ +'use strict' + +const RedirectHandler = require('../handler/RedirectHandler') + +function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections }) { + return (dispatch) => { + return function Intercept (opts, handler) { + const { maxRedirections = defaultMaxRedirections } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler(dispatch, maxRedirections, opts, handler) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } + } +} + +module.exports = createRedirectInterceptor diff --git a/lib/llhttp/constants.d.ts b/lib/llhttp/constants.d.ts new file mode 100644 index 0000000..b75ab1b --- /dev/null +++ b/lib/llhttp/constants.d.ts @@ -0,0 +1,199 @@ +import { IEnumMap } from './utils'; +export declare type HTTPMode = 'loose' | 'strict'; +export declare enum ERROR { + OK = 0, + INTERNAL = 1, + STRICT = 2, + LF_EXPECTED = 3, + UNEXPECTED_CONTENT_LENGTH = 4, + CLOSED_CONNECTION = 5, + INVALID_METHOD = 6, + INVALID_URL = 7, + INVALID_CONSTANT = 8, + INVALID_VERSION = 9, + INVALID_HEADER_TOKEN = 10, + INVALID_CONTENT_LENGTH = 11, + INVALID_CHUNK_SIZE = 12, + INVALID_STATUS = 13, + INVALID_EOF_STATE = 14, + INVALID_TRANSFER_ENCODING = 15, + CB_MESSAGE_BEGIN = 16, + CB_HEADERS_COMPLETE = 17, + CB_MESSAGE_COMPLETE = 18, + CB_CHUNK_HEADER = 19, + CB_CHUNK_COMPLETE = 20, + PAUSED = 21, + PAUSED_UPGRADE = 22, + PAUSED_H2_UPGRADE = 23, + USER = 24 +} +export declare enum TYPE { + BOTH = 0, + REQUEST = 1, + RESPONSE = 2 +} +export declare enum FLAGS { + CONNECTION_KEEP_ALIVE = 1, + CONNECTION_CLOSE = 2, + CONNECTION_UPGRADE = 4, + CHUNKED = 8, + UPGRADE = 16, + CONTENT_LENGTH = 32, + SKIPBODY = 64, + TRAILING = 128, + TRANSFER_ENCODING = 512 +} +export declare enum LENIENT_FLAGS { + HEADERS = 1, + CHUNKED_LENGTH = 2, + KEEP_ALIVE = 4 +} +export declare enum METHODS { + DELETE = 0, + GET = 1, + HEAD = 2, + POST = 3, + PUT = 4, + CONNECT = 5, + OPTIONS = 6, + TRACE = 7, + COPY = 8, + LOCK = 9, + MKCOL = 10, + MOVE = 11, + PROPFIND = 12, + PROPPATCH = 13, + SEARCH = 14, + UNLOCK = 15, + BIND = 16, + REBIND = 17, + UNBIND = 18, + ACL = 19, + REPORT = 20, + MKACTIVITY = 21, + CHECKOUT = 22, + MERGE = 23, + 'M-SEARCH' = 24, + NOTIFY = 25, + SUBSCRIBE = 26, + UNSUBSCRIBE = 27, + PATCH = 28, + PURGE = 29, + MKCALENDAR = 30, + LINK = 31, + UNLINK = 32, + SOURCE = 33, + PRI = 34, + DESCRIBE = 35, + ANNOUNCE = 36, + SETUP = 37, + PLAY = 38, + PAUSE = 39, + TEARDOWN = 40, + GET_PARAMETER = 41, + SET_PARAMETER = 42, + REDIRECT = 43, + RECORD = 44, + FLUSH = 45 +} +export declare const METHODS_HTTP: METHODS[]; +export declare const METHODS_ICE: METHODS[]; +export declare const METHODS_RTSP: METHODS[]; +export declare const METHOD_MAP: IEnumMap; +export declare const H_METHOD_MAP: IEnumMap; +export declare enum FINISH { + SAFE = 0, + SAFE_WITH_CB = 1, + UNSAFE = 2 +} +export declare type CharList = Array; +export declare const ALPHA: CharList; +export declare const NUM_MAP: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare const HEX_MAP: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; + A: number; + B: number; + C: number; + D: number; + E: number; + F: number; + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +}; +export declare const NUM: CharList; +export declare const ALPHANUM: CharList; +export declare const MARK: CharList; +export declare const USERINFO_CHARS: CharList; +export declare const STRICT_URL_CHAR: CharList; +export declare const URL_CHAR: CharList; +export declare const HEX: CharList; +export declare const STRICT_TOKEN: CharList; +export declare const TOKEN: CharList; +export declare const HEADER_CHARS: CharList; +export declare const CONNECTION_TOKEN_CHARS: CharList; +export declare const MAJOR: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare const MINOR: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare enum HEADER_STATE { + GENERAL = 0, + CONNECTION = 1, + CONTENT_LENGTH = 2, + TRANSFER_ENCODING = 3, + UPGRADE = 4, + CONNECTION_KEEP_ALIVE = 5, + CONNECTION_CLOSE = 6, + CONNECTION_UPGRADE = 7, + TRANSFER_ENCODING_CHUNKED = 8 +} +export declare const SPECIAL_HEADERS: { + connection: HEADER_STATE; + 'content-length': HEADER_STATE; + 'proxy-connection': HEADER_STATE; + 'transfer-encoding': HEADER_STATE; + upgrade: HEADER_STATE; +}; diff --git a/lib/llhttp/constants.js b/lib/llhttp/constants.js new file mode 100644 index 0000000..fb0b5a2 --- /dev/null +++ b/lib/llhttp/constants.js @@ -0,0 +1,278 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SPECIAL_HEADERS = exports.HEADER_STATE = exports.MINOR = exports.MAJOR = exports.CONNECTION_TOKEN_CHARS = exports.HEADER_CHARS = exports.TOKEN = exports.STRICT_TOKEN = exports.HEX = exports.URL_CHAR = exports.STRICT_URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.NUM = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.FINISH = exports.H_METHOD_MAP = exports.METHOD_MAP = exports.METHODS_RTSP = exports.METHODS_ICE = exports.METHODS_HTTP = exports.METHODS = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; +const utils_1 = require("./utils"); +// C headers +var ERROR; +(function (ERROR) { + ERROR[ERROR["OK"] = 0] = "OK"; + ERROR[ERROR["INTERNAL"] = 1] = "INTERNAL"; + ERROR[ERROR["STRICT"] = 2] = "STRICT"; + ERROR[ERROR["LF_EXPECTED"] = 3] = "LF_EXPECTED"; + ERROR[ERROR["UNEXPECTED_CONTENT_LENGTH"] = 4] = "UNEXPECTED_CONTENT_LENGTH"; + ERROR[ERROR["CLOSED_CONNECTION"] = 5] = "CLOSED_CONNECTION"; + ERROR[ERROR["INVALID_METHOD"] = 6] = "INVALID_METHOD"; + ERROR[ERROR["INVALID_URL"] = 7] = "INVALID_URL"; + ERROR[ERROR["INVALID_CONSTANT"] = 8] = "INVALID_CONSTANT"; + ERROR[ERROR["INVALID_VERSION"] = 9] = "INVALID_VERSION"; + ERROR[ERROR["INVALID_HEADER_TOKEN"] = 10] = "INVALID_HEADER_TOKEN"; + ERROR[ERROR["INVALID_CONTENT_LENGTH"] = 11] = "INVALID_CONTENT_LENGTH"; + ERROR[ERROR["INVALID_CHUNK_SIZE"] = 12] = "INVALID_CHUNK_SIZE"; + ERROR[ERROR["INVALID_STATUS"] = 13] = "INVALID_STATUS"; + ERROR[ERROR["INVALID_EOF_STATE"] = 14] = "INVALID_EOF_STATE"; + ERROR[ERROR["INVALID_TRANSFER_ENCODING"] = 15] = "INVALID_TRANSFER_ENCODING"; + ERROR[ERROR["CB_MESSAGE_BEGIN"] = 16] = "CB_MESSAGE_BEGIN"; + ERROR[ERROR["CB_HEADERS_COMPLETE"] = 17] = "CB_HEADERS_COMPLETE"; + ERROR[ERROR["CB_MESSAGE_COMPLETE"] = 18] = "CB_MESSAGE_COMPLETE"; + ERROR[ERROR["CB_CHUNK_HEADER"] = 19] = "CB_CHUNK_HEADER"; + ERROR[ERROR["CB_CHUNK_COMPLETE"] = 20] = "CB_CHUNK_COMPLETE"; + ERROR[ERROR["PAUSED"] = 21] = "PAUSED"; + ERROR[ERROR["PAUSED_UPGRADE"] = 22] = "PAUSED_UPGRADE"; + ERROR[ERROR["PAUSED_H2_UPGRADE"] = 23] = "PAUSED_H2_UPGRADE"; + ERROR[ERROR["USER"] = 24] = "USER"; +})(ERROR = exports.ERROR || (exports.ERROR = {})); +var TYPE; +(function (TYPE) { + TYPE[TYPE["BOTH"] = 0] = "BOTH"; + TYPE[TYPE["REQUEST"] = 1] = "REQUEST"; + TYPE[TYPE["RESPONSE"] = 2] = "RESPONSE"; +})(TYPE = exports.TYPE || (exports.TYPE = {})); +var FLAGS; +(function (FLAGS) { + FLAGS[FLAGS["CONNECTION_KEEP_ALIVE"] = 1] = "CONNECTION_KEEP_ALIVE"; + FLAGS[FLAGS["CONNECTION_CLOSE"] = 2] = "CONNECTION_CLOSE"; + FLAGS[FLAGS["CONNECTION_UPGRADE"] = 4] = "CONNECTION_UPGRADE"; + FLAGS[FLAGS["CHUNKED"] = 8] = "CHUNKED"; + FLAGS[FLAGS["UPGRADE"] = 16] = "UPGRADE"; + FLAGS[FLAGS["CONTENT_LENGTH"] = 32] = "CONTENT_LENGTH"; + FLAGS[FLAGS["SKIPBODY"] = 64] = "SKIPBODY"; + FLAGS[FLAGS["TRAILING"] = 128] = "TRAILING"; + // 1 << 8 is unused + FLAGS[FLAGS["TRANSFER_ENCODING"] = 512] = "TRANSFER_ENCODING"; +})(FLAGS = exports.FLAGS || (exports.FLAGS = {})); +var LENIENT_FLAGS; +(function (LENIENT_FLAGS) { + LENIENT_FLAGS[LENIENT_FLAGS["HEADERS"] = 1] = "HEADERS"; + LENIENT_FLAGS[LENIENT_FLAGS["CHUNKED_LENGTH"] = 2] = "CHUNKED_LENGTH"; + LENIENT_FLAGS[LENIENT_FLAGS["KEEP_ALIVE"] = 4] = "KEEP_ALIVE"; +})(LENIENT_FLAGS = exports.LENIENT_FLAGS || (exports.LENIENT_FLAGS = {})); +var METHODS; +(function (METHODS) { + METHODS[METHODS["DELETE"] = 0] = "DELETE"; + METHODS[METHODS["GET"] = 1] = "GET"; + METHODS[METHODS["HEAD"] = 2] = "HEAD"; + METHODS[METHODS["POST"] = 3] = "POST"; + METHODS[METHODS["PUT"] = 4] = "PUT"; + /* pathological */ + METHODS[METHODS["CONNECT"] = 5] = "CONNECT"; + METHODS[METHODS["OPTIONS"] = 6] = "OPTIONS"; + METHODS[METHODS["TRACE"] = 7] = "TRACE"; + /* WebDAV */ + METHODS[METHODS["COPY"] = 8] = "COPY"; + METHODS[METHODS["LOCK"] = 9] = "LOCK"; + METHODS[METHODS["MKCOL"] = 10] = "MKCOL"; + METHODS[METHODS["MOVE"] = 11] = "MOVE"; + METHODS[METHODS["PROPFIND"] = 12] = "PROPFIND"; + METHODS[METHODS["PROPPATCH"] = 13] = "PROPPATCH"; + METHODS[METHODS["SEARCH"] = 14] = "SEARCH"; + METHODS[METHODS["UNLOCK"] = 15] = "UNLOCK"; + METHODS[METHODS["BIND"] = 16] = "BIND"; + METHODS[METHODS["REBIND"] = 17] = "REBIND"; + METHODS[METHODS["UNBIND"] = 18] = "UNBIND"; + METHODS[METHODS["ACL"] = 19] = "ACL"; + /* subversion */ + METHODS[METHODS["REPORT"] = 20] = "REPORT"; + METHODS[METHODS["MKACTIVITY"] = 21] = "MKACTIVITY"; + METHODS[METHODS["CHECKOUT"] = 22] = "CHECKOUT"; + METHODS[METHODS["MERGE"] = 23] = "MERGE"; + /* upnp */ + METHODS[METHODS["M-SEARCH"] = 24] = "M-SEARCH"; + METHODS[METHODS["NOTIFY"] = 25] = "NOTIFY"; + METHODS[METHODS["SUBSCRIBE"] = 26] = "SUBSCRIBE"; + METHODS[METHODS["UNSUBSCRIBE"] = 27] = "UNSUBSCRIBE"; + /* RFC-5789 */ + METHODS[METHODS["PATCH"] = 28] = "PATCH"; + METHODS[METHODS["PURGE"] = 29] = "PURGE"; + /* CalDAV */ + METHODS[METHODS["MKCALENDAR"] = 30] = "MKCALENDAR"; + /* RFC-2068, section 19.6.1.2 */ + METHODS[METHODS["LINK"] = 31] = "LINK"; + METHODS[METHODS["UNLINK"] = 32] = "UNLINK"; + /* icecast */ + METHODS[METHODS["SOURCE"] = 33] = "SOURCE"; + /* RFC-7540, section 11.6 */ + METHODS[METHODS["PRI"] = 34] = "PRI"; + /* RFC-2326 RTSP */ + METHODS[METHODS["DESCRIBE"] = 35] = "DESCRIBE"; + METHODS[METHODS["ANNOUNCE"] = 36] = "ANNOUNCE"; + METHODS[METHODS["SETUP"] = 37] = "SETUP"; + METHODS[METHODS["PLAY"] = 38] = "PLAY"; + METHODS[METHODS["PAUSE"] = 39] = "PAUSE"; + METHODS[METHODS["TEARDOWN"] = 40] = "TEARDOWN"; + METHODS[METHODS["GET_PARAMETER"] = 41] = "GET_PARAMETER"; + METHODS[METHODS["SET_PARAMETER"] = 42] = "SET_PARAMETER"; + METHODS[METHODS["REDIRECT"] = 43] = "REDIRECT"; + METHODS[METHODS["RECORD"] = 44] = "RECORD"; + /* RAOP */ + METHODS[METHODS["FLUSH"] = 45] = "FLUSH"; +})(METHODS = exports.METHODS || (exports.METHODS = {})); +exports.METHODS_HTTP = [ + METHODS.DELETE, + METHODS.GET, + METHODS.HEAD, + METHODS.POST, + METHODS.PUT, + METHODS.CONNECT, + METHODS.OPTIONS, + METHODS.TRACE, + METHODS.COPY, + METHODS.LOCK, + METHODS.MKCOL, + METHODS.MOVE, + METHODS.PROPFIND, + METHODS.PROPPATCH, + METHODS.SEARCH, + METHODS.UNLOCK, + METHODS.BIND, + METHODS.REBIND, + METHODS.UNBIND, + METHODS.ACL, + METHODS.REPORT, + METHODS.MKACTIVITY, + METHODS.CHECKOUT, + METHODS.MERGE, + METHODS['M-SEARCH'], + METHODS.NOTIFY, + METHODS.SUBSCRIBE, + METHODS.UNSUBSCRIBE, + METHODS.PATCH, + METHODS.PURGE, + METHODS.MKCALENDAR, + METHODS.LINK, + METHODS.UNLINK, + METHODS.PRI, + // TODO(indutny): should we allow it with HTTP? + METHODS.SOURCE, +]; +exports.METHODS_ICE = [ + METHODS.SOURCE, +]; +exports.METHODS_RTSP = [ + METHODS.OPTIONS, + METHODS.DESCRIBE, + METHODS.ANNOUNCE, + METHODS.SETUP, + METHODS.PLAY, + METHODS.PAUSE, + METHODS.TEARDOWN, + METHODS.GET_PARAMETER, + METHODS.SET_PARAMETER, + METHODS.REDIRECT, + METHODS.RECORD, + METHODS.FLUSH, + // For AirPlay + METHODS.GET, + METHODS.POST, +]; +exports.METHOD_MAP = utils_1.enumToMap(METHODS); +exports.H_METHOD_MAP = {}; +Object.keys(exports.METHOD_MAP).forEach((key) => { + if (/^H/.test(key)) { + exports.H_METHOD_MAP[key] = exports.METHOD_MAP[key]; + } +}); +var FINISH; +(function (FINISH) { + FINISH[FINISH["SAFE"] = 0] = "SAFE"; + FINISH[FINISH["SAFE_WITH_CB"] = 1] = "SAFE_WITH_CB"; + FINISH[FINISH["UNSAFE"] = 2] = "UNSAFE"; +})(FINISH = exports.FINISH || (exports.FINISH = {})); +exports.ALPHA = []; +for (let i = 'A'.charCodeAt(0); i <= 'Z'.charCodeAt(0); i++) { + // Upper case + exports.ALPHA.push(String.fromCharCode(i)); + // Lower case + exports.ALPHA.push(String.fromCharCode(i + 0x20)); +} +exports.NUM_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, +}; +exports.HEX_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, + A: 0XA, B: 0XB, C: 0XC, D: 0XD, E: 0XE, F: 0XF, + a: 0xa, b: 0xb, c: 0xc, d: 0xd, e: 0xe, f: 0xf, +}; +exports.NUM = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', +]; +exports.ALPHANUM = exports.ALPHA.concat(exports.NUM); +exports.MARK = ['-', '_', '.', '!', '~', '*', '\'', '(', ')']; +exports.USERINFO_CHARS = exports.ALPHANUM + .concat(exports.MARK) + .concat(['%', ';', ':', '&', '=', '+', '$', ',']); +// TODO(indutny): use RFC +exports.STRICT_URL_CHAR = [ + '!', '"', '$', '%', '&', '\'', + '(', ')', '*', '+', ',', '-', '.', '/', + ':', ';', '<', '=', '>', + '@', '[', '\\', ']', '^', '_', + '`', + '{', '|', '}', '~', +].concat(exports.ALPHANUM); +exports.URL_CHAR = exports.STRICT_URL_CHAR + .concat(['\t', '\f']); +// All characters with 0x80 bit set to 1 +for (let i = 0x80; i <= 0xff; i++) { + exports.URL_CHAR.push(i); +} +exports.HEX = exports.NUM.concat(['a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F']); +/* Tokens as defined by rfc 2616. Also lowercases them. + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + */ +exports.STRICT_TOKEN = [ + '!', '#', '$', '%', '&', '\'', + '*', '+', '-', '.', + '^', '_', '`', + '|', '~', +].concat(exports.ALPHANUM); +exports.TOKEN = exports.STRICT_TOKEN.concat([' ']); +/* + * Verify that a char is a valid visible (printable) US-ASCII + * character or %x80-FF + */ +exports.HEADER_CHARS = ['\t']; +for (let i = 32; i <= 255; i++) { + if (i !== 127) { + exports.HEADER_CHARS.push(i); + } +} +// ',' = \x44 +exports.CONNECTION_TOKEN_CHARS = exports.HEADER_CHARS.filter((c) => c !== 44); +exports.MAJOR = exports.NUM_MAP; +exports.MINOR = exports.MAJOR; +var HEADER_STATE; +(function (HEADER_STATE) { + HEADER_STATE[HEADER_STATE["GENERAL"] = 0] = "GENERAL"; + HEADER_STATE[HEADER_STATE["CONNECTION"] = 1] = "CONNECTION"; + HEADER_STATE[HEADER_STATE["CONTENT_LENGTH"] = 2] = "CONTENT_LENGTH"; + HEADER_STATE[HEADER_STATE["TRANSFER_ENCODING"] = 3] = "TRANSFER_ENCODING"; + HEADER_STATE[HEADER_STATE["UPGRADE"] = 4] = "UPGRADE"; + HEADER_STATE[HEADER_STATE["CONNECTION_KEEP_ALIVE"] = 5] = "CONNECTION_KEEP_ALIVE"; + HEADER_STATE[HEADER_STATE["CONNECTION_CLOSE"] = 6] = "CONNECTION_CLOSE"; + HEADER_STATE[HEADER_STATE["CONNECTION_UPGRADE"] = 7] = "CONNECTION_UPGRADE"; + HEADER_STATE[HEADER_STATE["TRANSFER_ENCODING_CHUNKED"] = 8] = "TRANSFER_ENCODING_CHUNKED"; +})(HEADER_STATE = exports.HEADER_STATE || (exports.HEADER_STATE = {})); +exports.SPECIAL_HEADERS = { + 'connection': HEADER_STATE.CONNECTION, + 'content-length': HEADER_STATE.CONTENT_LENGTH, + 'proxy-connection': HEADER_STATE.CONNECTION, + 'transfer-encoding': HEADER_STATE.TRANSFER_ENCODING, + 'upgrade': HEADER_STATE.UPGRADE, +}; +//# sourceMappingURL=constants.js.map \ No newline at end of file diff --git a/lib/llhttp/utils.d.ts b/lib/llhttp/utils.d.ts new file mode 100644 index 0000000..15497f3 --- /dev/null +++ b/lib/llhttp/utils.d.ts @@ -0,0 +1,4 @@ +export interface IEnumMap { + [key: string]: number; +} +export declare function enumToMap(obj: any): IEnumMap; diff --git a/lib/llhttp/utils.js b/lib/llhttp/utils.js new file mode 100644 index 0000000..8a32e56 --- /dev/null +++ b/lib/llhttp/utils.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.enumToMap = void 0; +function enumToMap(obj) { + const res = {}; + Object.keys(obj).forEach((key) => { + const value = obj[key]; + if (typeof value === 'number') { + res[key] = value; + } + }); + return res; +} +exports.enumToMap = enumToMap; +//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/lib/llhttp/wasm_build_env.txt b/lib/llhttp/wasm_build_env.txt new file mode 100644 index 0000000..5f478b5 --- /dev/null +++ b/lib/llhttp/wasm_build_env.txt @@ -0,0 +1,32 @@ +alpine-baselayout-data-3.4.0-r0 +musl-1.2.3-r4 +busybox-1.35.0-r29 +busybox-binsh-1.35.0-r29 +alpine-baselayout-3.4.0-r0 +alpine-keys-2.4-r1 +ca-certificates-bundle-20220614-r4 +libcrypto3-3.0.8-r3 +libssl3-3.0.8-r3 +ssl_client-1.35.0-r29 +zlib-1.2.13-r0 +apk-tools-2.12.10-r1 +scanelf-1.3.5-r1 +musl-utils-1.2.3-r4 +libc-utils-0.7.2-r3 +libgcc-12.2.1_git20220924-r4 +libstdc++-12.2.1_git20220924-r4 +libffi-3.4.4-r0 +xz-libs-5.2.9-r0 +libxml2-2.10.4-r0 +zstd-libs-1.5.5-r0 +llvm15-libs-15.0.7-r0 +clang15-libs-15.0.7-r0 +libstdc++-dev-12.2.1_git20220924-r4 +clang15-15.0.7-r0 +lld-libs-15.0.7-r0 +lld-15.0.7-r0 +wasi-libc-0.20220525-r1 +wasi-libcxx-15.0.7-r0 +wasi-libcxxabi-15.0.7-r0 +wasi-compiler-rt-15.0.7-r0 +wasi-sdk-16-r0 diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js new file mode 100644 index 0000000..828e8af --- /dev/null +++ b/lib/mock/mock-agent.js @@ -0,0 +1,171 @@ +'use strict' + +const { kClients } = require('../core/symbols') +const Agent = require('../agent') +const { + kAgent, + kMockAgentSet, + kMockAgentGet, + kDispatches, + kIsMockActive, + kNetConnect, + kGetNetConnect, + kOptions, + kFactory +} = require('./mock-symbols') +const MockClient = require('./mock-client') +const MockPool = require('./mock-pool') +const { matchValue, buildMockOptions } = require('./mock-utils') +const { InvalidArgumentError, UndiciError } = require('../core/errors') +const Dispatcher = require('../dispatcher') +const Pluralizer = require('./pluralizer') +const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') + +class FakeWeakRef { + constructor (value) { + this.value = value + } + + deref () { + return this.value + } +} + +class MockAgent extends Dispatcher { + constructor (opts) { + super(opts) + + this[kNetConnect] = true + this[kIsMockActive] = true + + // Instantiate Agent and encapsulate + if ((opts && opts.agent && typeof opts.agent.dispatch !== 'function')) { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + const agent = opts && opts.agent ? opts.agent : new Agent(opts) + this[kAgent] = agent + + this[kClients] = agent[kClients] + this[kOptions] = buildMockOptions(opts) + } + + get (origin) { + let dispatcher = this[kMockAgentGet](origin) + + if (!dispatcher) { + dispatcher = this[kFactory](origin) + this[kMockAgentSet](origin, dispatcher) + } + return dispatcher + } + + dispatch (opts, handler) { + // Call MockAgent.get to perform additional setup before dispatching as normal + this.get(opts.origin) + return this[kAgent].dispatch(opts, handler) + } + + async close () { + await this[kAgent].close() + this[kClients].clear() + } + + deactivate () { + this[kIsMockActive] = false + } + + activate () { + this[kIsMockActive] = true + } + + enableNetConnect (matcher) { + if (typeof matcher === 'string' || typeof matcher === 'function' || matcher instanceof RegExp) { + if (Array.isArray(this[kNetConnect])) { + this[kNetConnect].push(matcher) + } else { + this[kNetConnect] = [matcher] + } + } else if (typeof matcher === 'undefined') { + this[kNetConnect] = true + } else { + throw new InvalidArgumentError('Unsupported matcher. Must be one of String|Function|RegExp.') + } + } + + disableNetConnect () { + this[kNetConnect] = false + } + + // This is required to bypass issues caused by using global symbols - see: + // https://github.com/nodejs/undici/issues/1447 + get isMockActive () { + return this[kIsMockActive] + } + + [kMockAgentSet] (origin, dispatcher) { + this[kClients].set(origin, new FakeWeakRef(dispatcher)) + } + + [kFactory] (origin) { + const mockOptions = Object.assign({ agent: this }, this[kOptions]) + return this[kOptions] && this[kOptions].connections === 1 + ? new MockClient(origin, mockOptions) + : new MockPool(origin, mockOptions) + } + + [kMockAgentGet] (origin) { + // First check if we can immediately find it + const ref = this[kClients].get(origin) + if (ref) { + return ref.deref() + } + + // If the origin is not a string create a dummy parent pool and return to user + if (typeof origin !== 'string') { + const dispatcher = this[kFactory]('http://localhost:9999') + this[kMockAgentSet](origin, dispatcher) + return dispatcher + } + + // If we match, create a pool and assign the same dispatches + for (const [keyMatcher, nonExplicitRef] of Array.from(this[kClients])) { + const nonExplicitDispatcher = nonExplicitRef.deref() + if (nonExplicitDispatcher && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) { + const dispatcher = this[kFactory](origin) + this[kMockAgentSet](origin, dispatcher) + dispatcher[kDispatches] = nonExplicitDispatcher[kDispatches] + return dispatcher + } + } + } + + [kGetNetConnect] () { + return this[kNetConnect] + } + + pendingInterceptors () { + const mockAgentClients = this[kClients] + + return Array.from(mockAgentClients.entries()) + .flatMap(([origin, scope]) => scope.deref()[kDispatches].map(dispatch => ({ ...dispatch, origin }))) + .filter(({ pending }) => pending) + } + + assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) { + const pending = this.pendingInterceptors() + + if (pending.length === 0) { + return + } + + const pluralizer = new Pluralizer('interceptor', 'interceptors').pluralize(pending.length) + + throw new UndiciError(` +${pluralizer.count} ${pluralizer.noun} ${pluralizer.is} pending: + +${pendingInterceptorsFormatter.format(pending)} +`.trim()) + } +} + +module.exports = MockAgent diff --git a/lib/mock/mock-client.js b/lib/mock/mock-client.js new file mode 100644 index 0000000..5f31215 --- /dev/null +++ b/lib/mock/mock-client.js @@ -0,0 +1,59 @@ +'use strict' + +const { promisify } = require('util') +const Client = require('../client') +const { buildMockDispatch } = require('./mock-utils') +const { + kDispatches, + kMockAgent, + kClose, + kOriginalClose, + kOrigin, + kOriginalDispatch, + kConnected +} = require('./mock-symbols') +const { MockInterceptor } = require('./mock-interceptor') +const Symbols = require('../core/symbols') +const { InvalidArgumentError } = require('../core/errors') + +/** + * MockClient provides an API that extends the Client to influence the mockDispatches. + */ +class MockClient extends Client { + constructor (origin, opts) { + super(origin, opts) + + if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + + this[kMockAgent] = opts.agent + this[kOrigin] = origin + this[kDispatches] = [] + this[kConnected] = 1 + this[kOriginalDispatch] = this.dispatch + this[kOriginalClose] = this.close.bind(this) + + this.dispatch = buildMockDispatch.call(this) + this.close = this[kClose] + } + + get [Symbols.kConnected] () { + return this[kConnected] + } + + /** + * Sets up the base interceptor for mocking replies from undici. + */ + intercept (opts) { + return new MockInterceptor(opts, this[kDispatches]) + } + + async [kClose] () { + await promisify(this[kOriginalClose])() + this[kConnected] = 0 + this[kMockAgent][Symbols.kClients].delete(this[kOrigin]) + } +} + +module.exports = MockClient diff --git a/lib/mock/mock-errors.js b/lib/mock/mock-errors.js new file mode 100644 index 0000000..5442c0e --- /dev/null +++ b/lib/mock/mock-errors.js @@ -0,0 +1,17 @@ +'use strict' + +const { UndiciError } = require('../core/errors') + +class MockNotMatchedError extends UndiciError { + constructor (message) { + super(message) + Error.captureStackTrace(this, MockNotMatchedError) + this.name = 'MockNotMatchedError' + this.message = message || 'The request does not match any registered mock dispatches' + this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED' + } +} + +module.exports = { + MockNotMatchedError +} diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js new file mode 100644 index 0000000..781e477 --- /dev/null +++ b/lib/mock/mock-interceptor.js @@ -0,0 +1,206 @@ +'use strict' + +const { getResponseData, buildKey, addMockDispatch } = require('./mock-utils') +const { + kDispatches, + kDispatchKey, + kDefaultHeaders, + kDefaultTrailers, + kContentLength, + kMockDispatch +} = require('./mock-symbols') +const { InvalidArgumentError } = require('../core/errors') +const { buildURL } = require('../core/util') + +/** + * Defines the scope API for an interceptor reply + */ +class MockScope { + constructor (mockDispatch) { + this[kMockDispatch] = mockDispatch + } + + /** + * Delay a reply by a set amount in ms. + */ + delay (waitInMs) { + if (typeof waitInMs !== 'number' || !Number.isInteger(waitInMs) || waitInMs <= 0) { + throw new InvalidArgumentError('waitInMs must be a valid integer > 0') + } + + this[kMockDispatch].delay = waitInMs + return this + } + + /** + * For a defined reply, never mark as consumed. + */ + persist () { + this[kMockDispatch].persist = true + return this + } + + /** + * Allow one to define a reply for a set amount of matching requests. + */ + times (repeatTimes) { + if (typeof repeatTimes !== 'number' || !Number.isInteger(repeatTimes) || repeatTimes <= 0) { + throw new InvalidArgumentError('repeatTimes must be a valid integer > 0') + } + + this[kMockDispatch].times = repeatTimes + return this + } +} + +/** + * Defines an interceptor for a Mock + */ +class MockInterceptor { + constructor (opts, mockDispatches) { + if (typeof opts !== 'object') { + throw new InvalidArgumentError('opts must be an object') + } + if (typeof opts.path === 'undefined') { + throw new InvalidArgumentError('opts.path must be defined') + } + if (typeof opts.method === 'undefined') { + opts.method = 'GET' + } + // See https://github.com/nodejs/undici/issues/1245 + // As per RFC 3986, clients are not supposed to send URI + // fragments to servers when they retrieve a document, + if (typeof opts.path === 'string') { + if (opts.query) { + opts.path = buildURL(opts.path, opts.query) + } else { + // Matches https://github.com/nodejs/undici/blob/main/lib/fetch/index.js#L1811 + const parsedURL = new URL(opts.path, 'data://') + opts.path = parsedURL.pathname + parsedURL.search + } + } + if (typeof opts.method === 'string') { + opts.method = opts.method.toUpperCase() + } + + this[kDispatchKey] = buildKey(opts) + this[kDispatches] = mockDispatches + this[kDefaultHeaders] = {} + this[kDefaultTrailers] = {} + this[kContentLength] = false + } + + createMockScopeDispatchData (statusCode, data, responseOptions = {}) { + const responseData = getResponseData(data) + const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {} + const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers } + const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers } + + return { statusCode, data, headers, trailers } + } + + validateReplyParameters (statusCode, data, responseOptions) { + if (typeof statusCode === 'undefined') { + throw new InvalidArgumentError('statusCode must be defined') + } + if (typeof data === 'undefined') { + throw new InvalidArgumentError('data must be defined') + } + if (typeof responseOptions !== 'object') { + throw new InvalidArgumentError('responseOptions must be an object') + } + } + + /** + * Mock an undici request with a defined reply. + */ + reply (replyData) { + // Values of reply aren't available right now as they + // can only be available when the reply callback is invoked. + if (typeof replyData === 'function') { + // We'll first wrap the provided callback in another function, + // this function will properly resolve the data from the callback + // when invoked. + const wrappedDefaultsCallback = (opts) => { + // Our reply options callback contains the parameter for statusCode, data and options. + const resolvedData = replyData(opts) + + // Check if it is in the right format + if (typeof resolvedData !== 'object') { + throw new InvalidArgumentError('reply options callback must return an object') + } + + const { statusCode, data = '', responseOptions = {} } = resolvedData + this.validateReplyParameters(statusCode, data, responseOptions) + // Since the values can be obtained immediately we return them + // from this higher order function that will be resolved later. + return { + ...this.createMockScopeDispatchData(statusCode, data, responseOptions) + } + } + + // Add usual dispatch data, but this time set the data parameter to function that will eventually provide data. + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback) + return new MockScope(newMockDispatch) + } + + // We can have either one or three parameters, if we get here, + // we should have 1-3 parameters. So we spread the arguments of + // this function to obtain the parameters, since replyData will always + // just be the statusCode. + const [statusCode, data = '', responseOptions = {}] = [...arguments] + this.validateReplyParameters(statusCode, data, responseOptions) + + // Send in-already provided data like usual + const dispatchData = this.createMockScopeDispatchData(statusCode, data, responseOptions) + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData) + return new MockScope(newMockDispatch) + } + + /** + * Mock an undici request with a defined error. + */ + replyWithError (error) { + if (typeof error === 'undefined') { + throw new InvalidArgumentError('error must be defined') + } + + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error }) + return new MockScope(newMockDispatch) + } + + /** + * Set default reply headers on the interceptor for subsequent replies + */ + defaultReplyHeaders (headers) { + if (typeof headers === 'undefined') { + throw new InvalidArgumentError('headers must be defined') + } + + this[kDefaultHeaders] = headers + return this + } + + /** + * Set default reply trailers on the interceptor for subsequent replies + */ + defaultReplyTrailers (trailers) { + if (typeof trailers === 'undefined') { + throw new InvalidArgumentError('trailers must be defined') + } + + this[kDefaultTrailers] = trailers + return this + } + + /** + * Set reply content length header for replies on the interceptor + */ + replyContentLength () { + this[kContentLength] = true + return this + } +} + +module.exports.MockInterceptor = MockInterceptor +module.exports.MockScope = MockScope diff --git a/lib/mock/mock-pool.js b/lib/mock/mock-pool.js new file mode 100644 index 0000000..0a3a7cd --- /dev/null +++ b/lib/mock/mock-pool.js @@ -0,0 +1,59 @@ +'use strict' + +const { promisify } = require('util') +const Pool = require('../pool') +const { buildMockDispatch } = require('./mock-utils') +const { + kDispatches, + kMockAgent, + kClose, + kOriginalClose, + kOrigin, + kOriginalDispatch, + kConnected +} = require('./mock-symbols') +const { MockInterceptor } = require('./mock-interceptor') +const Symbols = require('../core/symbols') +const { InvalidArgumentError } = require('../core/errors') + +/** + * MockPool provides an API that extends the Pool to influence the mockDispatches. + */ +class MockPool extends Pool { + constructor (origin, opts) { + super(origin, opts) + + if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + + this[kMockAgent] = opts.agent + this[kOrigin] = origin + this[kDispatches] = [] + this[kConnected] = 1 + this[kOriginalDispatch] = this.dispatch + this[kOriginalClose] = this.close.bind(this) + + this.dispatch = buildMockDispatch.call(this) + this.close = this[kClose] + } + + get [Symbols.kConnected] () { + return this[kConnected] + } + + /** + * Sets up the base interceptor for mocking replies from undici. + */ + intercept (opts) { + return new MockInterceptor(opts, this[kDispatches]) + } + + async [kClose] () { + await promisify(this[kOriginalClose])() + this[kConnected] = 0 + this[kMockAgent][Symbols.kClients].delete(this[kOrigin]) + } +} + +module.exports = MockPool diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js new file mode 100644 index 0000000..8c4cbb6 --- /dev/null +++ b/lib/mock/mock-symbols.js @@ -0,0 +1,23 @@ +'use strict' + +module.exports = { + kAgent: Symbol('agent'), + kOptions: Symbol('options'), + kFactory: Symbol('factory'), + kDispatches: Symbol('dispatches'), + kDispatchKey: Symbol('dispatch key'), + kDefaultHeaders: Symbol('default headers'), + kDefaultTrailers: Symbol('default trailers'), + kContentLength: Symbol('content length'), + kMockAgent: Symbol('mock agent'), + kMockAgentSet: Symbol('mock agent set'), + kMockAgentGet: Symbol('mock agent get'), + kMockDispatch: Symbol('mock dispatch'), + kClose: Symbol('close'), + kOriginalClose: Symbol('original agent close'), + kOrigin: Symbol('origin'), + kIsMockActive: Symbol('is mock active'), + kNetConnect: Symbol('net connect'), + kGetNetConnect: Symbol('get net connect'), + kConnected: Symbol('connected') +} diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js new file mode 100644 index 0000000..42ea185 --- /dev/null +++ b/lib/mock/mock-utils.js @@ -0,0 +1,351 @@ +'use strict' + +const { MockNotMatchedError } = require('./mock-errors') +const { + kDispatches, + kMockAgent, + kOriginalDispatch, + kOrigin, + kGetNetConnect +} = require('./mock-symbols') +const { buildURL, nop } = require('../core/util') +const { STATUS_CODES } = require('http') +const { + types: { + isPromise + } +} = require('util') + +function matchValue (match, value) { + if (typeof match === 'string') { + return match === value + } + if (match instanceof RegExp) { + return match.test(value) + } + if (typeof match === 'function') { + return match(value) === true + } + return false +} + +function lowerCaseEntries (headers) { + return Object.fromEntries( + Object.entries(headers).map(([headerName, headerValue]) => { + return [headerName.toLocaleLowerCase(), headerValue] + }) + ) +} + +/** + * @param {import('../../index').Headers|string[]|Record} headers + * @param {string} key + */ +function getHeaderByName (headers, key) { + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) { + return headers[i + 1] + } + } + + return undefined + } else if (typeof headers.get === 'function') { + return headers.get(key) + } else { + return lowerCaseEntries(headers)[key.toLocaleLowerCase()] + } +} + +/** @param {string[]} headers */ +function buildHeadersFromArray (headers) { // fetch HeadersList + const clone = headers.slice() + const entries = [] + for (let index = 0; index < clone.length; index += 2) { + entries.push([clone[index], clone[index + 1]]) + } + return Object.fromEntries(entries) +} + +function matchHeaders (mockDispatch, headers) { + if (typeof mockDispatch.headers === 'function') { + if (Array.isArray(headers)) { // fetch HeadersList + headers = buildHeadersFromArray(headers) + } + return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {}) + } + if (typeof mockDispatch.headers === 'undefined') { + return true + } + if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') { + return false + } + + for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) { + const headerValue = getHeaderByName(headers, matchHeaderName) + + if (!matchValue(matchHeaderValue, headerValue)) { + return false + } + } + return true +} + +function safeUrl (path) { + if (typeof path !== 'string') { + return path + } + + const pathSegments = path.split('?') + + if (pathSegments.length !== 2) { + return path + } + + const qp = new URLSearchParams(pathSegments.pop()) + qp.sort() + return [...pathSegments, qp.toString()].join('?') +} + +function matchKey (mockDispatch, { path, method, body, headers }) { + const pathMatch = matchValue(mockDispatch.path, path) + const methodMatch = matchValue(mockDispatch.method, method) + const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true + const headersMatch = matchHeaders(mockDispatch, headers) + return pathMatch && methodMatch && bodyMatch && headersMatch +} + +function getResponseData (data) { + if (Buffer.isBuffer(data)) { + return data + } else if (typeof data === 'object') { + return JSON.stringify(data) + } else { + return data.toString() + } +} + +function getMockDispatch (mockDispatches, key) { + const basePath = key.query ? buildURL(key.path, key.query) : key.path + const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath + + // Match path + let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath)) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`) + } + + // Match method + matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}'`) + } + + // Match body + matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}'`) + } + + // Match headers + matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for headers '${typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers}'`) + } + + return matchedMockDispatches[0] +} + +function addMockDispatch (mockDispatches, key, data) { + const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false } + const replyData = typeof data === 'function' ? { callback: data } : { ...data } + const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } } + mockDispatches.push(newMockDispatch) + return newMockDispatch +} + +function deleteMockDispatch (mockDispatches, key) { + const index = mockDispatches.findIndex(dispatch => { + if (!dispatch.consumed) { + return false + } + return matchKey(dispatch, key) + }) + if (index !== -1) { + mockDispatches.splice(index, 1) + } +} + +function buildKey (opts) { + const { path, method, body, headers, query } = opts + return { + path, + method, + body, + headers, + query + } +} + +function generateKeyValues (data) { + return Object.entries(data).reduce((keyValuePairs, [key, value]) => [ + ...keyValuePairs, + Buffer.from(`${key}`), + Array.isArray(value) ? value.map(x => Buffer.from(`${x}`)) : Buffer.from(`${value}`) + ], []) +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + * @param {number} statusCode + */ +function getStatusText (statusCode) { + return STATUS_CODES[statusCode] || 'unknown' +} + +async function getResponse (body) { + const buffers = [] + for await (const data of body) { + buffers.push(data) + } + return Buffer.concat(buffers).toString('utf8') +} + +/** + * Mock dispatch function used to simulate undici dispatches + */ +function mockDispatch (opts, handler) { + // Get mock dispatch from built key + const key = buildKey(opts) + const mockDispatch = getMockDispatch(this[kDispatches], key) + + mockDispatch.timesInvoked++ + + // Here's where we resolve a callback if a callback is present for the dispatch data. + if (mockDispatch.data.callback) { + mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } + } + + // Parse mockDispatch data + const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch + const { timesInvoked, times } = mockDispatch + + // If it's used up and not persistent, mark as consumed + mockDispatch.consumed = !persist && timesInvoked >= times + mockDispatch.pending = timesInvoked < times + + // If specified, trigger dispatch error + if (error !== null) { + deleteMockDispatch(this[kDispatches], key) + handler.onError(error) + return true + } + + // Handle the request with a delay if necessary + if (typeof delay === 'number' && delay > 0) { + setTimeout(() => { + handleReply(this[kDispatches]) + }, delay) + } else { + handleReply(this[kDispatches]) + } + + function handleReply (mockDispatches, _data = data) { + // fetch's HeadersList is a 1D string array + const optsHeaders = Array.isArray(opts.headers) + ? buildHeadersFromArray(opts.headers) + : opts.headers + const body = typeof _data === 'function' + ? _data({ ...opts, headers: optsHeaders }) + : _data + + // util.types.isPromise is likely needed for jest. + if (isPromise(body)) { + // If handleReply is asynchronous, throwing an error + // in the callback will reject the promise, rather than + // synchronously throw the error, which breaks some tests. + // Rather, we wait for the callback to resolve if it is a + // promise, and then re-run handleReply with the new body. + body.then((newData) => handleReply(mockDispatches, newData)) + return + } + + const responseData = getResponseData(body) + const responseHeaders = generateKeyValues(headers) + const responseTrailers = generateKeyValues(trailers) + + handler.abort = nop + handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode)) + handler.onData(Buffer.from(responseData)) + handler.onComplete(responseTrailers) + deleteMockDispatch(mockDispatches, key) + } + + function resume () {} + + return true +} + +function buildMockDispatch () { + const agent = this[kMockAgent] + const origin = this[kOrigin] + const originalDispatch = this[kOriginalDispatch] + + return function dispatch (opts, handler) { + if (agent.isMockActive) { + try { + mockDispatch.call(this, opts, handler) + } catch (error) { + if (error instanceof MockNotMatchedError) { + const netConnect = agent[kGetNetConnect]() + if (netConnect === false) { + throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`) + } + if (checkNetConnect(netConnect, origin)) { + originalDispatch.call(this, opts, handler) + } else { + throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`) + } + } else { + throw error + } + } + } else { + originalDispatch.call(this, opts, handler) + } + } +} + +function checkNetConnect (netConnect, origin) { + const url = new URL(origin) + if (netConnect === true) { + return true + } else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) { + return true + } + return false +} + +function buildMockOptions (opts) { + if (opts) { + const { agent, ...mockOptions } = opts + return mockOptions + } +} + +module.exports = { + getResponseData, + getMockDispatch, + addMockDispatch, + deleteMockDispatch, + buildKey, + generateKeyValues, + matchValue, + getResponse, + getStatusText, + mockDispatch, + buildMockDispatch, + checkNetConnect, + buildMockOptions, + getHeaderByName +} diff --git a/lib/mock/pending-interceptors-formatter.js b/lib/mock/pending-interceptors-formatter.js new file mode 100644 index 0000000..1bc7539 --- /dev/null +++ b/lib/mock/pending-interceptors-formatter.js @@ -0,0 +1,40 @@ +'use strict' + +const { Transform } = require('stream') +const { Console } = require('console') + +/** + * Gets the output of `console.table(…)` as a string. + */ +module.exports = class PendingInterceptorsFormatter { + constructor ({ disableColors } = {}) { + this.transform = new Transform({ + transform (chunk, _enc, cb) { + cb(null, chunk) + } + }) + + this.logger = new Console({ + stdout: this.transform, + inspectOptions: { + colors: !disableColors && !process.env.CI + } + }) + } + + format (pendingInterceptors) { + const withPrettyHeaders = pendingInterceptors.map( + ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin }) => ({ + Method: method, + Origin: origin, + Path: path, + 'Status code': statusCode, + Persistent: persist ? '✅' : '❌', + Invocations: timesInvoked, + Remaining: persist ? Infinity : times - timesInvoked + })) + + this.logger.table(withPrettyHeaders) + return this.transform.read().toString() + } +} diff --git a/lib/mock/pluralizer.js b/lib/mock/pluralizer.js new file mode 100644 index 0000000..47f150b --- /dev/null +++ b/lib/mock/pluralizer.js @@ -0,0 +1,29 @@ +'use strict' + +const singulars = { + pronoun: 'it', + is: 'is', + was: 'was', + this: 'this' +} + +const plurals = { + pronoun: 'they', + is: 'are', + was: 'were', + this: 'these' +} + +module.exports = class Pluralizer { + constructor (singular, plural) { + this.singular = singular + this.plural = plural + } + + pluralize (count) { + const one = count === 1 + const keys = one ? singulars : plurals + const noun = one ? this.singular : this.plural + return { ...keys, count, noun } + } +} diff --git a/lib/node/fixed-queue.js b/lib/node/fixed-queue.js new file mode 100644 index 0000000..3572681 --- /dev/null +++ b/lib/node/fixed-queue.js @@ -0,0 +1,117 @@ +/* eslint-disable */ + +'use strict' + +// Extracted from node/lib/internal/fixed_queue.js + +// Currently optimal queue size, tested on V8 6.0 - 6.6. Must be power of two. +const kSize = 2048; +const kMask = kSize - 1; + +// The FixedQueue is implemented as a singly-linked list of fixed-size +// circular buffers. It looks something like this: +// +// head tail +// | | +// v v +// +-----------+ <-----\ +-----------+ <------\ +-----------+ +// | [null] | \----- | next | \------- | next | +// +-----------+ +-----------+ +-----------+ +// | item | <-- bottom | item | <-- bottom | [empty] | +// | item | | item | | [empty] | +// | item | | item | | [empty] | +// | item | | item | | [empty] | +// | item | | item | bottom --> | item | +// | item | | item | | item | +// | ... | | ... | | ... | +// | item | | item | | item | +// | item | | item | | item | +// | [empty] | <-- top | item | | item | +// | [empty] | | item | | item | +// | [empty] | | [empty] | <-- top top --> | [empty] | +// +-----------+ +-----------+ +-----------+ +// +// Or, if there is only one circular buffer, it looks something +// like either of these: +// +// head tail head tail +// | | | | +// v v v v +// +-----------+ +-----------+ +// | [null] | | [null] | +// +-----------+ +-----------+ +// | [empty] | | item | +// | [empty] | | item | +// | item | <-- bottom top --> | [empty] | +// | item | | [empty] | +// | [empty] | <-- top bottom --> | item | +// | [empty] | | item | +// +-----------+ +-----------+ +// +// Adding a value means moving `top` forward by one, removing means +// moving `bottom` forward by one. After reaching the end, the queue +// wraps around. +// +// When `top === bottom` the current queue is empty and when +// `top + 1 === bottom` it's full. This wastes a single space of storage +// but allows much quicker checks. + +class FixedCircularBuffer { + constructor() { + this.bottom = 0; + this.top = 0; + this.list = new Array(kSize); + this.next = null; + } + + isEmpty() { + return this.top === this.bottom; + } + + isFull() { + return ((this.top + 1) & kMask) === this.bottom; + } + + push(data) { + this.list[this.top] = data; + this.top = (this.top + 1) & kMask; + } + + shift() { + const nextItem = this.list[this.bottom]; + if (nextItem === undefined) + return null; + this.list[this.bottom] = undefined; + this.bottom = (this.bottom + 1) & kMask; + return nextItem; + } +} + +module.exports = class FixedQueue { + constructor() { + this.head = this.tail = new FixedCircularBuffer(); + } + + isEmpty() { + return this.head.isEmpty(); + } + + push(data) { + if (this.head.isFull()) { + // Head is full: Creates a new queue, sets the old queue's `.next` to it, + // and sets it as the new main queue. + this.head = this.head.next = new FixedCircularBuffer(); + } + this.head.push(data); + } + + shift() { + const tail = this.tail; + const next = tail.shift(); + if (tail.isEmpty() && tail.next !== null) { + // If there is another queue, it forms the new tail. + this.tail = tail.next; + } + return next; + } +}; diff --git a/lib/pool-base.js b/lib/pool-base.js new file mode 100644 index 0000000..2a909ee --- /dev/null +++ b/lib/pool-base.js @@ -0,0 +1,194 @@ +'use strict' + +const DispatcherBase = require('./dispatcher-base') +const FixedQueue = require('./node/fixed-queue') +const { kConnected, kSize, kRunning, kPending, kQueued, kBusy, kFree, kUrl, kClose, kDestroy, kDispatch } = require('./core/symbols') +const PoolStats = require('./pool-stats') + +const kClients = Symbol('clients') +const kNeedDrain = Symbol('needDrain') +const kQueue = Symbol('queue') +const kClosedResolve = Symbol('closed resolve') +const kOnDrain = Symbol('onDrain') +const kOnConnect = Symbol('onConnect') +const kOnDisconnect = Symbol('onDisconnect') +const kOnConnectionError = Symbol('onConnectionError') +const kGetDispatcher = Symbol('get dispatcher') +const kAddClient = Symbol('add client') +const kRemoveClient = Symbol('remove client') +const kStats = Symbol('stats') + +class PoolBase extends DispatcherBase { + constructor () { + super() + + this[kQueue] = new FixedQueue() + this[kClients] = [] + this[kQueued] = 0 + + const pool = this + + this[kOnDrain] = function onDrain (origin, targets) { + const queue = pool[kQueue] + + let needDrain = false + + while (!needDrain) { + const item = queue.shift() + if (!item) { + break + } + pool[kQueued]-- + needDrain = !this.dispatch(item.opts, item.handler) + } + + this[kNeedDrain] = needDrain + + if (!this[kNeedDrain] && pool[kNeedDrain]) { + pool[kNeedDrain] = false + pool.emit('drain', origin, [pool, ...targets]) + } + + if (pool[kClosedResolve] && queue.isEmpty()) { + Promise + .all(pool[kClients].map(c => c.close())) + .then(pool[kClosedResolve]) + } + } + + this[kOnConnect] = (origin, targets) => { + pool.emit('connect', origin, [pool, ...targets]) + } + + this[kOnDisconnect] = (origin, targets, err) => { + pool.emit('disconnect', origin, [pool, ...targets], err) + } + + this[kOnConnectionError] = (origin, targets, err) => { + pool.emit('connectionError', origin, [pool, ...targets], err) + } + + this[kStats] = new PoolStats(this) + } + + get [kBusy] () { + return this[kNeedDrain] + } + + get [kConnected] () { + return this[kClients].filter(client => client[kConnected]).length + } + + get [kFree] () { + return this[kClients].filter(client => client[kConnected] && !client[kNeedDrain]).length + } + + get [kPending] () { + let ret = this[kQueued] + for (const { [kPending]: pending } of this[kClients]) { + ret += pending + } + return ret + } + + get [kRunning] () { + let ret = 0 + for (const { [kRunning]: running } of this[kClients]) { + ret += running + } + return ret + } + + get [kSize] () { + let ret = this[kQueued] + for (const { [kSize]: size } of this[kClients]) { + ret += size + } + return ret + } + + get stats () { + return this[kStats] + } + + async [kClose] () { + if (this[kQueue].isEmpty()) { + return Promise.all(this[kClients].map(c => c.close())) + } else { + return new Promise((resolve) => { + this[kClosedResolve] = resolve + }) + } + } + + async [kDestroy] (err) { + while (true) { + const item = this[kQueue].shift() + if (!item) { + break + } + item.handler.onError(err) + } + + return Promise.all(this[kClients].map(c => c.destroy(err))) + } + + [kDispatch] (opts, handler) { + const dispatcher = this[kGetDispatcher]() + + if (!dispatcher) { + this[kNeedDrain] = true + this[kQueue].push({ opts, handler }) + this[kQueued]++ + } else if (!dispatcher.dispatch(opts, handler)) { + dispatcher[kNeedDrain] = true + this[kNeedDrain] = !this[kGetDispatcher]() + } + + return !this[kNeedDrain] + } + + [kAddClient] (client) { + client + .on('drain', this[kOnDrain]) + .on('connect', this[kOnConnect]) + .on('disconnect', this[kOnDisconnect]) + .on('connectionError', this[kOnConnectionError]) + + this[kClients].push(client) + + if (this[kNeedDrain]) { + process.nextTick(() => { + if (this[kNeedDrain]) { + this[kOnDrain](client[kUrl], [this, client]) + } + }) + } + + return this + } + + [kRemoveClient] (client) { + client.close(() => { + const idx = this[kClients].indexOf(client) + if (idx !== -1) { + this[kClients].splice(idx, 1) + } + }) + + this[kNeedDrain] = this[kClients].some(dispatcher => ( + !dispatcher[kNeedDrain] && + dispatcher.closed !== true && + dispatcher.destroyed !== true + )) + } +} + +module.exports = { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kRemoveClient, + kGetDispatcher +} diff --git a/lib/pool-stats.js b/lib/pool-stats.js new file mode 100644 index 0000000..b4af8ae --- /dev/null +++ b/lib/pool-stats.js @@ -0,0 +1,34 @@ +const { kFree, kConnected, kPending, kQueued, kRunning, kSize } = require('./core/symbols') +const kPool = Symbol('pool') + +class PoolStats { + constructor (pool) { + this[kPool] = pool + } + + get connected () { + return this[kPool][kConnected] + } + + get free () { + return this[kPool][kFree] + } + + get pending () { + return this[kPool][kPending] + } + + get queued () { + return this[kPool][kQueued] + } + + get running () { + return this[kPool][kRunning] + } + + get size () { + return this[kPool][kSize] + } +} + +module.exports = PoolStats diff --git a/lib/pool.js b/lib/pool.js new file mode 100644 index 0000000..e3cd339 --- /dev/null +++ b/lib/pool.js @@ -0,0 +1,94 @@ +'use strict' + +const { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kGetDispatcher +} = require('./pool-base') +const Client = require('./client') +const { + InvalidArgumentError +} = require('./core/errors') +const util = require('./core/util') +const { kUrl, kInterceptors } = require('./core/symbols') +const buildConnector = require('./core/connect') + +const kOptions = Symbol('options') +const kConnections = Symbol('connections') +const kFactory = Symbol('factory') + +function defaultFactory (origin, opts) { + return new Client(origin, opts) +} + +class Pool extends PoolBase { + constructor (origin, { + connections, + factory = defaultFactory, + connect, + connectTimeout, + tls, + maxCachedSessions, + socketPath, + autoSelectFamily, + autoSelectFamilyAttemptTimeout, + allowH2, + ...options + } = {}) { + super() + + if (connections != null && (!Number.isFinite(connections) || connections < 0)) { + throw new InvalidArgumentError('invalid connections') + } + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + if (typeof connect !== 'function') { + connect = buildConnector({ + ...tls, + maxCachedSessions, + allowH2, + socketPath, + timeout: connectTimeout, + ...(util.nodeHasAutoSelectFamily && autoSelectFamily ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), + ...connect + }) + } + + this[kInterceptors] = options.interceptors && options.interceptors.Pool && Array.isArray(options.interceptors.Pool) + ? options.interceptors.Pool + : [] + this[kConnections] = connections || null + this[kUrl] = util.parseOrigin(origin) + this[kOptions] = { ...util.deepClone(options), connect, allowH2 } + this[kOptions].interceptors = options.interceptors + ? { ...options.interceptors } + : undefined + this[kFactory] = factory + } + + [kGetDispatcher] () { + let dispatcher = this[kClients].find(dispatcher => !dispatcher[kNeedDrain]) + + if (dispatcher) { + return dispatcher + } + + if (!this[kConnections] || this[kClients].length < this[kConnections]) { + dispatcher = this[kFactory](this[kUrl], this[kOptions]) + this[kAddClient](dispatcher) + } + + return dispatcher + } +} + +module.exports = Pool diff --git a/lib/proxy-agent.js b/lib/proxy-agent.js new file mode 100644 index 0000000..e3c0f6f --- /dev/null +++ b/lib/proxy-agent.js @@ -0,0 +1,189 @@ +'use strict' + +const { kProxy, kClose, kDestroy, kInterceptors } = require('./core/symbols') +const { URL } = require('url') +const Agent = require('./agent') +const Pool = require('./pool') +const DispatcherBase = require('./dispatcher-base') +const { InvalidArgumentError, RequestAbortedError } = require('./core/errors') +const buildConnector = require('./core/connect') + +const kAgent = Symbol('proxy agent') +const kClient = Symbol('proxy client') +const kProxyHeaders = Symbol('proxy headers') +const kRequestTls = Symbol('request tls settings') +const kProxyTls = Symbol('proxy tls settings') +const kConnectEndpoint = Symbol('connect endpoint function') + +function defaultProtocolPort (protocol) { + return protocol === 'https:' ? 443 : 80 +} + +function buildProxyOptions (opts) { + if (typeof opts === 'string') { + opts = { uri: opts } + } + + if (!opts || !opts.uri) { + throw new InvalidArgumentError('Proxy opts.uri is mandatory') + } + + return { + uri: opts.uri, + protocol: opts.protocol || 'https' + } +} + +function defaultFactory (origin, opts) { + return new Pool(origin, opts) +} + +class ProxyAgent extends DispatcherBase { + constructor (opts) { + super(opts) + this[kProxy] = buildProxyOptions(opts) + this[kAgent] = new Agent(opts) + this[kInterceptors] = opts.interceptors && opts.interceptors.ProxyAgent && Array.isArray(opts.interceptors.ProxyAgent) + ? opts.interceptors.ProxyAgent + : [] + + if (typeof opts === 'string') { + opts = { uri: opts } + } + + if (!opts || !opts.uri) { + throw new InvalidArgumentError('Proxy opts.uri is mandatory') + } + + const { clientFactory = defaultFactory } = opts + + if (typeof clientFactory !== 'function') { + throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.') + } + + this[kRequestTls] = opts.requestTls + this[kProxyTls] = opts.proxyTls + this[kProxyHeaders] = opts.headers || {} + + const resolvedUrl = new URL(opts.uri) + const { origin, port, host, username, password } = resolvedUrl + + if (opts.auth && opts.token) { + throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') + } else if (opts.auth) { + /* @deprecated in favour of opts.token */ + this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}` + } else if (opts.token) { + this[kProxyHeaders]['proxy-authorization'] = opts.token + } else if (username && password) { + this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` + } + + const connect = buildConnector({ ...opts.proxyTls }) + this[kConnectEndpoint] = buildConnector({ ...opts.requestTls }) + this[kClient] = clientFactory(resolvedUrl, { connect }) + this[kAgent] = new Agent({ + ...opts, + connect: async (opts, callback) => { + let requestedHost = opts.host + if (!opts.port) { + requestedHost += `:${defaultProtocolPort(opts.protocol)}` + } + try { + const { socket, statusCode } = await this[kClient].connect({ + origin, + port, + path: requestedHost, + signal: opts.signal, + headers: { + ...this[kProxyHeaders], + host + } + }) + if (statusCode !== 200) { + socket.on('error', () => {}).destroy() + callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`)) + } + if (opts.protocol !== 'https:') { + callback(null, socket) + return + } + let servername + if (this[kRequestTls]) { + servername = this[kRequestTls].servername + } else { + servername = opts.servername + } + this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback) + } catch (err) { + callback(err) + } + } + }) + } + + dispatch (opts, handler) { + const { host } = new URL(opts.origin) + const headers = buildHeaders(opts.headers) + throwIfProxyAuthIsSent(headers) + return this[kAgent].dispatch( + { + ...opts, + headers: { + ...headers, + host + } + }, + handler + ) + } + + async [kClose] () { + await this[kAgent].close() + await this[kClient].close() + } + + async [kDestroy] () { + await this[kAgent].destroy() + await this[kClient].destroy() + } +} + +/** + * @param {string[] | Record} headers + * @returns {Record} + */ +function buildHeaders (headers) { + // When using undici.fetch, the headers list is stored + // as an array. + if (Array.isArray(headers)) { + /** @type {Record} */ + const headersPair = {} + + for (let i = 0; i < headers.length; i += 2) { + headersPair[headers[i]] = headers[i + 1] + } + + return headersPair + } + + return headers +} + +/** + * @param {Record} headers + * + * Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers + * Nevertheless, it was changed and to avoid a security vulnerability by end users + * this check was created. + * It should be removed in the next major version for performance reasons + */ +function throwIfProxyAuthIsSent (headers) { + const existProxyAuth = headers && Object.keys(headers) + .find((key) => key.toLowerCase() === 'proxy-authorization') + if (existProxyAuth) { + throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor') + } +} + +module.exports = ProxyAgent diff --git a/lib/timers.js b/lib/timers.js new file mode 100644 index 0000000..5782217 --- /dev/null +++ b/lib/timers.js @@ -0,0 +1,97 @@ +'use strict' + +let fastNow = Date.now() +let fastNowTimeout + +const fastTimers = [] + +function onTimeout () { + fastNow = Date.now() + + let len = fastTimers.length + let idx = 0 + while (idx < len) { + const timer = fastTimers[idx] + + if (timer.state === 0) { + timer.state = fastNow + timer.delay + } else if (timer.state > 0 && fastNow >= timer.state) { + timer.state = -1 + timer.callback(timer.opaque) + } + + if (timer.state === -1) { + timer.state = -2 + if (idx !== len - 1) { + fastTimers[idx] = fastTimers.pop() + } else { + fastTimers.pop() + } + len -= 1 + } else { + idx += 1 + } + } + + if (fastTimers.length > 0) { + refreshTimeout() + } +} + +function refreshTimeout () { + if (fastNowTimeout && fastNowTimeout.refresh) { + fastNowTimeout.refresh() + } else { + clearTimeout(fastNowTimeout) + fastNowTimeout = setTimeout(onTimeout, 1e3) + if (fastNowTimeout.unref) { + fastNowTimeout.unref() + } + } +} + +class Timeout { + constructor (callback, delay, opaque) { + this.callback = callback + this.delay = delay + this.opaque = opaque + + // -2 not in timer list + // -1 in timer list but inactive + // 0 in timer list waiting for time + // > 0 in timer list waiting for time to expire + this.state = -2 + + this.refresh() + } + + refresh () { + if (this.state === -2) { + fastTimers.push(this) + if (!fastNowTimeout || fastTimers.length === 1) { + refreshTimeout() + } + } + + this.state = 0 + } + + clear () { + this.state = -1 + } +} + +module.exports = { + setTimeout (callback, delay, opaque) { + return delay < 1e3 + ? setTimeout(callback, delay, opaque) + : new Timeout(callback, delay, opaque) + }, + clearTimeout (timeout) { + if (timeout instanceof Timeout) { + timeout.clear() + } else { + clearTimeout(timeout) + } + } +} diff --git a/lib/websocket/connection.js b/lib/websocket/connection.js new file mode 100644 index 0000000..e0fa697 --- /dev/null +++ b/lib/websocket/connection.js @@ -0,0 +1,291 @@ +'use strict' + +const diagnosticsChannel = require('diagnostics_channel') +const { uid, states } = require('./constants') +const { + kReadyState, + kSentClose, + kByteParser, + kReceivedClose +} = require('./symbols') +const { fireEvent, failWebsocketConnection } = require('./util') +const { CloseEvent } = require('./events') +const { makeRequest } = require('../fetch/request') +const { fetching } = require('../fetch/index') +const { Headers } = require('../fetch/headers') +const { getGlobalDispatcher } = require('../global') +const { kHeadersList } = require('../core/symbols') + +const channels = {} +channels.open = diagnosticsChannel.channel('undici:websocket:open') +channels.close = diagnosticsChannel.channel('undici:websocket:close') +channels.socketError = diagnosticsChannel.channel('undici:websocket:socket_error') + +/** @type {import('crypto')} */ +let crypto +try { + crypto = require('crypto') +} catch { + +} + +/** + * @see https://websockets.spec.whatwg.org/#concept-websocket-establish + * @param {URL} url + * @param {string|string[]} protocols + * @param {import('./websocket').WebSocket} ws + * @param {(response: any) => void} onEstablish + * @param {Partial} options + */ +function establishWebSocketConnection (url, protocols, ws, onEstablish, options) { + // 1. Let requestURL be a copy of url, with its scheme set to "http", if url’s + // scheme is "ws", and to "https" otherwise. + const requestURL = url + + requestURL.protocol = url.protocol === 'ws:' ? 'http:' : 'https:' + + // 2. Let request be a new request, whose URL is requestURL, client is client, + // service-workers mode is "none", referrer is "no-referrer", mode is + // "websocket", credentials mode is "include", cache mode is "no-store" , + // and redirect mode is "error". + const request = makeRequest({ + urlList: [requestURL], + serviceWorkers: 'none', + referrer: 'no-referrer', + mode: 'websocket', + credentials: 'include', + cache: 'no-store', + redirect: 'error' + }) + + // Note: undici extension, allow setting custom headers. + if (options.headers) { + const headersList = new Headers(options.headers)[kHeadersList] + + request.headersList = headersList + } + + // 3. Append (`Upgrade`, `websocket`) to request’s header list. + // 4. Append (`Connection`, `Upgrade`) to request’s header list. + // Note: both of these are handled by undici currently. + // https://github.com/nodejs/undici/blob/68c269c4144c446f3f1220951338daef4a6b5ec4/lib/client.js#L1397 + + // 5. Let keyValue be a nonce consisting of a randomly selected + // 16-byte value that has been forgiving-base64-encoded and + // isomorphic encoded. + const keyValue = crypto.randomBytes(16).toString('base64') + + // 6. Append (`Sec-WebSocket-Key`, keyValue) to request’s + // header list. + request.headersList.append('sec-websocket-key', keyValue) + + // 7. Append (`Sec-WebSocket-Version`, `13`) to request’s + // header list. + request.headersList.append('sec-websocket-version', '13') + + // 8. For each protocol in protocols, combine + // (`Sec-WebSocket-Protocol`, protocol) in request’s header + // list. + for (const protocol of protocols) { + request.headersList.append('sec-websocket-protocol', protocol) + } + + // 9. Let permessageDeflate be a user-agent defined + // "permessage-deflate" extension header value. + // https://github.com/mozilla/gecko-dev/blob/ce78234f5e653a5d3916813ff990f053510227bc/netwerk/protocol/websocket/WebSocketChannel.cpp#L2673 + // TODO: enable once permessage-deflate is supported + const permessageDeflate = '' // 'permessage-deflate; 15' + + // 10. Append (`Sec-WebSocket-Extensions`, permessageDeflate) to + // request’s header list. + // request.headersList.append('sec-websocket-extensions', permessageDeflate) + + // 11. Fetch request with useParallelQueue set to true, and + // processResponse given response being these steps: + const controller = fetching({ + request, + useParallelQueue: true, + dispatcher: options.dispatcher ?? getGlobalDispatcher(), + processResponse (response) { + // 1. If response is a network error or its status is not 101, + // fail the WebSocket connection. + if (response.type === 'error' || response.status !== 101) { + failWebsocketConnection(ws, 'Received network error or non-101 status code.') + return + } + + // 2. If protocols is not the empty list and extracting header + // list values given `Sec-WebSocket-Protocol` and response’s + // header list results in null, failure, or the empty byte + // sequence, then fail the WebSocket connection. + if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) { + failWebsocketConnection(ws, 'Server did not respond with sent protocols.') + return + } + + // 3. Follow the requirements stated step 2 to step 6, inclusive, + // of the last set of steps in section 4.1 of The WebSocket + // Protocol to validate response. This either results in fail + // the WebSocket connection or the WebSocket connection is + // established. + + // 2. If the response lacks an |Upgrade| header field or the |Upgrade| + // header field contains a value that is not an ASCII case- + // insensitive match for the value "websocket", the client MUST + // _Fail the WebSocket Connection_. + if (response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') { + failWebsocketConnection(ws, 'Server did not set Upgrade header to "websocket".') + return + } + + // 3. If the response lacks a |Connection| header field or the + // |Connection| header field doesn't contain a token that is an + // ASCII case-insensitive match for the value "Upgrade", the client + // MUST _Fail the WebSocket Connection_. + if (response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') { + failWebsocketConnection(ws, 'Server did not set Connection header to "upgrade".') + return + } + + // 4. If the response lacks a |Sec-WebSocket-Accept| header field or + // the |Sec-WebSocket-Accept| contains a value other than the + // base64-encoded SHA-1 of the concatenation of the |Sec-WebSocket- + // Key| (as a string, not base64-decoded) with the string "258EAFA5- + // E914-47DA-95CA-C5AB0DC85B11" but ignoring any leading and + // trailing whitespace, the client MUST _Fail the WebSocket + // Connection_. + const secWSAccept = response.headersList.get('Sec-WebSocket-Accept') + const digest = crypto.createHash('sha1').update(keyValue + uid).digest('base64') + if (secWSAccept !== digest) { + failWebsocketConnection(ws, 'Incorrect hash received in Sec-WebSocket-Accept header.') + return + } + + // 5. If the response includes a |Sec-WebSocket-Extensions| header + // field and this header field indicates the use of an extension + // that was not present in the client's handshake (the server has + // indicated an extension not requested by the client), the client + // MUST _Fail the WebSocket Connection_. (The parsing of this + // header field to determine which extensions are requested is + // discussed in Section 9.1.) + const secExtension = response.headersList.get('Sec-WebSocket-Extensions') + + if (secExtension !== null && secExtension !== permessageDeflate) { + failWebsocketConnection(ws, 'Received different permessage-deflate than the one set.') + return + } + + // 6. If the response includes a |Sec-WebSocket-Protocol| header field + // and this header field indicates the use of a subprotocol that was + // not present in the client's handshake (the server has indicated a + // subprotocol not requested by the client), the client MUST _Fail + // the WebSocket Connection_. + const secProtocol = response.headersList.get('Sec-WebSocket-Protocol') + + if (secProtocol !== null && secProtocol !== request.headersList.get('Sec-WebSocket-Protocol')) { + failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.') + return + } + + response.socket.on('data', onSocketData) + response.socket.on('close', onSocketClose) + response.socket.on('error', onSocketError) + + if (channels.open.hasSubscribers) { + channels.open.publish({ + address: response.socket.address(), + protocol: secProtocol, + extensions: secExtension + }) + } + + onEstablish(response) + } + }) + + return controller +} + +/** + * @param {Buffer} chunk + */ +function onSocketData (chunk) { + if (!this.ws[kByteParser].write(chunk)) { + this.pause() + } +} + +/** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 + */ +function onSocketClose () { + const { ws } = this + + // If the TCP connection was closed after the + // WebSocket closing handshake was completed, the WebSocket connection + // is said to have been closed _cleanly_. + const wasClean = ws[kSentClose] && ws[kReceivedClose] + + let code = 1005 + let reason = '' + + const result = ws[kByteParser].closingInfo + + if (result) { + code = result.code ?? 1005 + reason = result.reason + } else if (!ws[kSentClose]) { + // If _The WebSocket + // Connection is Closed_ and no Close control frame was received by the + // endpoint (such as could occur if the underlying transport connection + // is lost), _The WebSocket Connection Close Code_ is considered to be + // 1006. + code = 1006 + } + + // 1. Change the ready state to CLOSED (3). + ws[kReadyState] = states.CLOSED + + // 2. If the user agent was required to fail the WebSocket + // connection, or if the WebSocket connection was closed + // after being flagged as full, fire an event named error + // at the WebSocket object. + // TODO + + // 3. Fire an event named close at the WebSocket object, + // using CloseEvent, with the wasClean attribute + // initialized to true if the connection closed cleanly + // and false otherwise, the code attribute initialized to + // the WebSocket connection close code, and the reason + // attribute initialized to the result of applying UTF-8 + // decode without BOM to the WebSocket connection close + // reason. + fireEvent('close', ws, CloseEvent, { + wasClean, code, reason + }) + + if (channels.close.hasSubscribers) { + channels.close.publish({ + websocket: ws, + code, + reason + }) + } +} + +function onSocketError (error) { + const { ws } = this + + ws[kReadyState] = states.CLOSING + + if (channels.socketError.hasSubscribers) { + channels.socketError.publish(error) + } + + this.destroy() +} + +module.exports = { + establishWebSocketConnection +} diff --git a/lib/websocket/constants.js b/lib/websocket/constants.js new file mode 100644 index 0000000..406b8e3 --- /dev/null +++ b/lib/websocket/constants.js @@ -0,0 +1,51 @@ +'use strict' + +// This is a Globally Unique Identifier unique used +// to validate that the endpoint accepts websocket +// connections. +// See https://www.rfc-editor.org/rfc/rfc6455.html#section-1.3 +const uid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +/** @type {PropertyDescriptor} */ +const staticPropertyDescriptors = { + enumerable: true, + writable: false, + configurable: false +} + +const states = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +} + +const opcodes = { + CONTINUATION: 0x0, + TEXT: 0x1, + BINARY: 0x2, + CLOSE: 0x8, + PING: 0x9, + PONG: 0xA +} + +const maxUnsigned16Bit = 2 ** 16 - 1 // 65535 + +const parserStates = { + INFO: 0, + PAYLOADLENGTH_16: 2, + PAYLOADLENGTH_64: 3, + READ_DATA: 4 +} + +const emptyBuffer = Buffer.allocUnsafe(0) + +module.exports = { + uid, + staticPropertyDescriptors, + states, + opcodes, + maxUnsigned16Bit, + parserStates, + emptyBuffer +} diff --git a/lib/websocket/events.js b/lib/websocket/events.js new file mode 100644 index 0000000..621a226 --- /dev/null +++ b/lib/websocket/events.js @@ -0,0 +1,303 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') +const { kEnumerableProperty } = require('../core/util') +const { MessagePort } = require('worker_threads') + +/** + * @see https://html.spec.whatwg.org/multipage/comms.html#messageevent + */ +class MessageEvent extends Event { + #eventInit + + constructor (type, eventInitDict = {}) { + webidl.argumentLengthCheck(arguments, 1, { header: 'MessageEvent constructor' }) + + type = webidl.converters.DOMString(type) + eventInitDict = webidl.converters.MessageEventInit(eventInitDict) + + super(type, eventInitDict) + + this.#eventInit = eventInitDict + } + + get data () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.data + } + + get origin () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.origin + } + + get lastEventId () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.lastEventId + } + + get source () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.source + } + + get ports () { + webidl.brandCheck(this, MessageEvent) + + if (!Object.isFrozen(this.#eventInit.ports)) { + Object.freeze(this.#eventInit.ports) + } + + return this.#eventInit.ports + } + + initMessageEvent ( + type, + bubbles = false, + cancelable = false, + data = null, + origin = '', + lastEventId = '', + source = null, + ports = [] + ) { + webidl.brandCheck(this, MessageEvent) + + webidl.argumentLengthCheck(arguments, 1, { header: 'MessageEvent.initMessageEvent' }) + + return new MessageEvent(type, { + bubbles, cancelable, data, origin, lastEventId, source, ports + }) + } +} + +/** + * @see https://websockets.spec.whatwg.org/#the-closeevent-interface + */ +class CloseEvent extends Event { + #eventInit + + constructor (type, eventInitDict = {}) { + webidl.argumentLengthCheck(arguments, 1, { header: 'CloseEvent constructor' }) + + type = webidl.converters.DOMString(type) + eventInitDict = webidl.converters.CloseEventInit(eventInitDict) + + super(type, eventInitDict) + + this.#eventInit = eventInitDict + } + + get wasClean () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.wasClean + } + + get code () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.code + } + + get reason () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.reason + } +} + +// https://html.spec.whatwg.org/multipage/webappapis.html#the-errorevent-interface +class ErrorEvent extends Event { + #eventInit + + constructor (type, eventInitDict) { + webidl.argumentLengthCheck(arguments, 1, { header: 'ErrorEvent constructor' }) + + super(type, eventInitDict) + + type = webidl.converters.DOMString(type) + eventInitDict = webidl.converters.ErrorEventInit(eventInitDict ?? {}) + + this.#eventInit = eventInitDict + } + + get message () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.message + } + + get filename () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.filename + } + + get lineno () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.lineno + } + + get colno () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.colno + } + + get error () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.error + } +} + +Object.defineProperties(MessageEvent.prototype, { + [Symbol.toStringTag]: { + value: 'MessageEvent', + configurable: true + }, + data: kEnumerableProperty, + origin: kEnumerableProperty, + lastEventId: kEnumerableProperty, + source: kEnumerableProperty, + ports: kEnumerableProperty, + initMessageEvent: kEnumerableProperty +}) + +Object.defineProperties(CloseEvent.prototype, { + [Symbol.toStringTag]: { + value: 'CloseEvent', + configurable: true + }, + reason: kEnumerableProperty, + code: kEnumerableProperty, + wasClean: kEnumerableProperty +}) + +Object.defineProperties(ErrorEvent.prototype, { + [Symbol.toStringTag]: { + value: 'ErrorEvent', + configurable: true + }, + message: kEnumerableProperty, + filename: kEnumerableProperty, + lineno: kEnumerableProperty, + colno: kEnumerableProperty, + error: kEnumerableProperty +}) + +webidl.converters.MessagePort = webidl.interfaceConverter(MessagePort) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.MessagePort +) + +const eventInit = [ + { + key: 'bubbles', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'cancelable', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'composed', + converter: webidl.converters.boolean, + defaultValue: false + } +] + +webidl.converters.MessageEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'data', + converter: webidl.converters.any, + defaultValue: null + }, + { + key: 'origin', + converter: webidl.converters.USVString, + defaultValue: '' + }, + { + key: 'lastEventId', + converter: webidl.converters.DOMString, + defaultValue: '' + }, + { + key: 'source', + // Node doesn't implement WindowProxy or ServiceWorker, so the only + // valid value for source is a MessagePort. + converter: webidl.nullableConverter(webidl.converters.MessagePort), + defaultValue: null + }, + { + key: 'ports', + converter: webidl.converters['sequence'], + get defaultValue () { + return [] + } + } +]) + +webidl.converters.CloseEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'wasClean', + converter: webidl.converters.boolean, + defaultValue: false + }, + { + key: 'code', + converter: webidl.converters['unsigned short'], + defaultValue: 0 + }, + { + key: 'reason', + converter: webidl.converters.USVString, + defaultValue: '' + } +]) + +webidl.converters.ErrorEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'message', + converter: webidl.converters.DOMString, + defaultValue: '' + }, + { + key: 'filename', + converter: webidl.converters.USVString, + defaultValue: '' + }, + { + key: 'lineno', + converter: webidl.converters['unsigned long'], + defaultValue: 0 + }, + { + key: 'colno', + converter: webidl.converters['unsigned long'], + defaultValue: 0 + }, + { + key: 'error', + converter: webidl.converters.any + } +]) + +module.exports = { + MessageEvent, + CloseEvent, + ErrorEvent +} diff --git a/lib/websocket/frame.js b/lib/websocket/frame.js new file mode 100644 index 0000000..d867ad1 --- /dev/null +++ b/lib/websocket/frame.js @@ -0,0 +1,73 @@ +'use strict' + +const { maxUnsigned16Bit } = require('./constants') + +/** @type {import('crypto')} */ +let crypto +try { + crypto = require('crypto') +} catch { + +} + +class WebsocketFrameSend { + /** + * @param {Buffer|undefined} data + */ + constructor (data) { + this.frameData = data + this.maskKey = crypto.randomBytes(4) + } + + createFrame (opcode) { + const bodyLength = this.frameData?.byteLength ?? 0 + + /** @type {number} */ + let payloadLength = bodyLength // 0-125 + let offset = 6 + + if (bodyLength > maxUnsigned16Bit) { + offset += 8 // payload length is next 8 bytes + payloadLength = 127 + } else if (bodyLength > 125) { + offset += 2 // payload length is next 2 bytes + payloadLength = 126 + } + + const buffer = Buffer.allocUnsafe(bodyLength + offset) + + // Clear first 2 bytes, everything else is overwritten + buffer[0] = buffer[1] = 0 + buffer[0] |= 0x80 // FIN + buffer[0] = (buffer[0] & 0xF0) + opcode // opcode + + /*! ws. MIT License. Einar Otto Stangvik */ + buffer[offset - 4] = this.maskKey[0] + buffer[offset - 3] = this.maskKey[1] + buffer[offset - 2] = this.maskKey[2] + buffer[offset - 1] = this.maskKey[3] + + buffer[1] = payloadLength + + if (payloadLength === 126) { + buffer.writeUInt16BE(bodyLength, 2) + } else if (payloadLength === 127) { + // Clear extended payload length + buffer[2] = buffer[3] = 0 + buffer.writeUIntBE(bodyLength, 4, 6) + } + + buffer[1] |= 0x80 // MASK + + // mask body + for (let i = 0; i < bodyLength; i++) { + buffer[offset + i] = this.frameData[i] ^ this.maskKey[i % 4] + } + + return buffer + } +} + +module.exports = { + WebsocketFrameSend +} diff --git a/lib/websocket/receiver.js b/lib/websocket/receiver.js new file mode 100644 index 0000000..bdd2031 --- /dev/null +++ b/lib/websocket/receiver.js @@ -0,0 +1,344 @@ +'use strict' + +const { Writable } = require('stream') +const diagnosticsChannel = require('diagnostics_channel') +const { parserStates, opcodes, states, emptyBuffer } = require('./constants') +const { kReadyState, kSentClose, kResponse, kReceivedClose } = require('./symbols') +const { isValidStatusCode, failWebsocketConnection, websocketMessageReceived } = require('./util') +const { WebsocketFrameSend } = require('./frame') + +// This code was influenced by ws released under the MIT license. +// Copyright (c) 2011 Einar Otto Stangvik +// Copyright (c) 2013 Arnout Kazemier and contributors +// Copyright (c) 2016 Luigi Pinca and contributors + +const channels = {} +channels.ping = diagnosticsChannel.channel('undici:websocket:ping') +channels.pong = diagnosticsChannel.channel('undici:websocket:pong') + +class ByteParser extends Writable { + #buffers = [] + #byteOffset = 0 + + #state = parserStates.INFO + + #info = {} + #fragments = [] + + constructor (ws) { + super() + + this.ws = ws + } + + /** + * @param {Buffer} chunk + * @param {() => void} callback + */ + _write (chunk, _, callback) { + this.#buffers.push(chunk) + this.#byteOffset += chunk.length + + this.run(callback) + } + + /** + * Runs whenever a new chunk is received. + * Callback is called whenever there are no more chunks buffering, + * or not enough bytes are buffered to parse. + */ + run (callback) { + while (true) { + if (this.#state === parserStates.INFO) { + // If there aren't enough bytes to parse the payload length, etc. + if (this.#byteOffset < 2) { + return callback() + } + + const buffer = this.consume(2) + + this.#info.fin = (buffer[0] & 0x80) !== 0 + this.#info.opcode = buffer[0] & 0x0F + + // If we receive a fragmented message, we use the type of the first + // frame to parse the full message as binary/text, when it's terminated + this.#info.originalOpcode ??= this.#info.opcode + + this.#info.fragmented = !this.#info.fin && this.#info.opcode !== opcodes.CONTINUATION + + if (this.#info.fragmented && this.#info.opcode !== opcodes.BINARY && this.#info.opcode !== opcodes.TEXT) { + // Only text and binary frames can be fragmented + failWebsocketConnection(this.ws, 'Invalid frame type was fragmented.') + return + } + + const payloadLength = buffer[1] & 0x7F + + if (payloadLength <= 125) { + this.#info.payloadLength = payloadLength + this.#state = parserStates.READ_DATA + } else if (payloadLength === 126) { + this.#state = parserStates.PAYLOADLENGTH_16 + } else if (payloadLength === 127) { + this.#state = parserStates.PAYLOADLENGTH_64 + } + + if (this.#info.fragmented && payloadLength > 125) { + // A fragmented frame can't be fragmented itself + failWebsocketConnection(this.ws, 'Fragmented frame exceeded 125 bytes.') + return + } else if ( + (this.#info.opcode === opcodes.PING || + this.#info.opcode === opcodes.PONG || + this.#info.opcode === opcodes.CLOSE) && + payloadLength > 125 + ) { + // Control frames can have a payload length of 125 bytes MAX + failWebsocketConnection(this.ws, 'Payload length for control frame exceeded 125 bytes.') + return + } else if (this.#info.opcode === opcodes.CLOSE) { + if (payloadLength === 1) { + failWebsocketConnection(this.ws, 'Received close frame with a 1-byte body.') + return + } + + const body = this.consume(payloadLength) + + this.#info.closeInfo = this.parseCloseBody(false, body) + + if (!this.ws[kSentClose]) { + // If an endpoint receives a Close frame and did not previously send a + // Close frame, the endpoint MUST send a Close frame in response. (When + // sending a Close frame in response, the endpoint typically echos the + // status code it received.) + const body = Buffer.allocUnsafe(2) + body.writeUInt16BE(this.#info.closeInfo.code, 0) + const closeFrame = new WebsocketFrameSend(body) + + this.ws[kResponse].socket.write( + closeFrame.createFrame(opcodes.CLOSE), + (err) => { + if (!err) { + this.ws[kSentClose] = true + } + } + ) + } + + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + this.ws[kReadyState] = states.CLOSING + this.ws[kReceivedClose] = true + + this.end() + + return + } else if (this.#info.opcode === opcodes.PING) { + // Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in + // response, unless it already received a Close frame. + // A Pong frame sent in response to a Ping frame must have identical + // "Application data" + + const body = this.consume(payloadLength) + + if (!this.ws[kReceivedClose]) { + const frame = new WebsocketFrameSend(body) + + this.ws[kResponse].socket.write(frame.createFrame(opcodes.PONG)) + + if (channels.ping.hasSubscribers) { + channels.ping.publish({ + payload: body + }) + } + } + + this.#state = parserStates.INFO + + if (this.#byteOffset > 0) { + continue + } else { + callback() + return + } + } else if (this.#info.opcode === opcodes.PONG) { + // A Pong frame MAY be sent unsolicited. This serves as a + // unidirectional heartbeat. A response to an unsolicited Pong frame is + // not expected. + + const body = this.consume(payloadLength) + + if (channels.pong.hasSubscribers) { + channels.pong.publish({ + payload: body + }) + } + + if (this.#byteOffset > 0) { + continue + } else { + callback() + return + } + } + } else if (this.#state === parserStates.PAYLOADLENGTH_16) { + if (this.#byteOffset < 2) { + return callback() + } + + const buffer = this.consume(2) + + this.#info.payloadLength = buffer.readUInt16BE(0) + this.#state = parserStates.READ_DATA + } else if (this.#state === parserStates.PAYLOADLENGTH_64) { + if (this.#byteOffset < 8) { + return callback() + } + + const buffer = this.consume(8) + const upper = buffer.readUInt32BE(0) + + // 2^31 is the maxinimum bytes an arraybuffer can contain + // on 32-bit systems. Although, on 64-bit systems, this is + // 2^53-1 bytes. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275 + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e + if (upper > 2 ** 31 - 1) { + failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.') + return + } + + const lower = buffer.readUInt32BE(4) + + this.#info.payloadLength = (upper << 8) + lower + this.#state = parserStates.READ_DATA + } else if (this.#state === parserStates.READ_DATA) { + if (this.#byteOffset < this.#info.payloadLength) { + // If there is still more data in this chunk that needs to be read + return callback() + } else if (this.#byteOffset >= this.#info.payloadLength) { + // If the server sent multiple frames in a single chunk + + const body = this.consume(this.#info.payloadLength) + + this.#fragments.push(body) + + // If the frame is unfragmented, or a fragmented frame was terminated, + // a message was received + if (!this.#info.fragmented || (this.#info.fin && this.#info.opcode === opcodes.CONTINUATION)) { + const fullMessage = Buffer.concat(this.#fragments) + + websocketMessageReceived(this.ws, this.#info.originalOpcode, fullMessage) + + this.#info = {} + this.#fragments.length = 0 + } + + this.#state = parserStates.INFO + } + } + + if (this.#byteOffset > 0) { + continue + } else { + callback() + break + } + } + } + + /** + * Take n bytes from the buffered Buffers + * @param {number} n + * @returns {Buffer|null} + */ + consume (n) { + if (n > this.#byteOffset) { + return null + } else if (n === 0) { + return emptyBuffer + } + + if (this.#buffers[0].length === n) { + this.#byteOffset -= this.#buffers[0].length + return this.#buffers.shift() + } + + const buffer = Buffer.allocUnsafe(n) + let offset = 0 + + while (offset !== n) { + const next = this.#buffers[0] + const { length } = next + + if (length + offset === n) { + buffer.set(this.#buffers.shift(), offset) + break + } else if (length + offset > n) { + buffer.set(next.subarray(0, n - offset), offset) + this.#buffers[0] = next.subarray(n - offset) + break + } else { + buffer.set(this.#buffers.shift(), offset) + offset += next.length + } + } + + this.#byteOffset -= n + + return buffer + } + + parseCloseBody (onlyCode, data) { + // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5 + /** @type {number|undefined} */ + let code + + if (data.length >= 2) { + // _The WebSocket Connection Close Code_ is + // defined as the status code (Section 7.4) contained in the first Close + // control frame received by the application + code = data.readUInt16BE(0) + } + + if (onlyCode) { + if (!isValidStatusCode(code)) { + return null + } + + return { code } + } + + // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.6 + /** @type {Buffer} */ + let reason = data.subarray(2) + + // Remove BOM + if (reason[0] === 0xEF && reason[1] === 0xBB && reason[2] === 0xBF) { + reason = reason.subarray(3) + } + + if (code !== undefined && !isValidStatusCode(code)) { + return null + } + + try { + // TODO: optimize this + reason = new TextDecoder('utf-8', { fatal: true }).decode(reason) + } catch { + return null + } + + return { code, reason } + } + + get closingInfo () { + return this.#info.closeInfo + } +} + +module.exports = { + ByteParser +} diff --git a/lib/websocket/symbols.js b/lib/websocket/symbols.js new file mode 100644 index 0000000..11d03e3 --- /dev/null +++ b/lib/websocket/symbols.js @@ -0,0 +1,12 @@ +'use strict' + +module.exports = { + kWebSocketURL: Symbol('url'), + kReadyState: Symbol('ready state'), + kController: Symbol('controller'), + kResponse: Symbol('response'), + kBinaryType: Symbol('binary type'), + kSentClose: Symbol('sent close'), + kReceivedClose: Symbol('received close'), + kByteParser: Symbol('byte parser') +} diff --git a/lib/websocket/util.js b/lib/websocket/util.js new file mode 100644 index 0000000..6c59b2c --- /dev/null +++ b/lib/websocket/util.js @@ -0,0 +1,200 @@ +'use strict' + +const { kReadyState, kController, kResponse, kBinaryType, kWebSocketURL } = require('./symbols') +const { states, opcodes } = require('./constants') +const { MessageEvent, ErrorEvent } = require('./events') + +/* globals Blob */ + +/** + * @param {import('./websocket').WebSocket} ws + */ +function isEstablished (ws) { + // If the server's response is validated as provided for above, it is + // said that _The WebSocket Connection is Established_ and that the + // WebSocket Connection is in the OPEN state. + return ws[kReadyState] === states.OPEN +} + +/** + * @param {import('./websocket').WebSocket} ws + */ +function isClosing (ws) { + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + return ws[kReadyState] === states.CLOSING +} + +/** + * @param {import('./websocket').WebSocket} ws + */ +function isClosed (ws) { + return ws[kReadyState] === states.CLOSED +} + +/** + * @see https://dom.spec.whatwg.org/#concept-event-fire + * @param {string} e + * @param {EventTarget} target + * @param {EventInit | undefined} eventInitDict + */ +function fireEvent (e, target, eventConstructor = Event, eventInitDict) { + // 1. If eventConstructor is not given, then let eventConstructor be Event. + + // 2. Let event be the result of creating an event given eventConstructor, + // in the relevant realm of target. + // 3. Initialize event’s type attribute to e. + const event = new eventConstructor(e, eventInitDict) // eslint-disable-line new-cap + + // 4. Initialize any other IDL attributes of event as described in the + // invocation of this algorithm. + + // 5. Return the result of dispatching event at target, with legacy target + // override flag set if set. + target.dispatchEvent(event) +} + +/** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + * @param {import('./websocket').WebSocket} ws + * @param {number} type Opcode + * @param {Buffer} data application data + */ +function websocketMessageReceived (ws, type, data) { + // 1. If ready state is not OPEN (1), then return. + if (ws[kReadyState] !== states.OPEN) { + return + } + + // 2. Let dataForEvent be determined by switching on type and binary type: + let dataForEvent + + if (type === opcodes.TEXT) { + // -> type indicates that the data is Text + // a new DOMString containing data + try { + dataForEvent = new TextDecoder('utf-8', { fatal: true }).decode(data) + } catch { + failWebsocketConnection(ws, 'Received invalid UTF-8 in text frame.') + return + } + } else if (type === opcodes.BINARY) { + if (ws[kBinaryType] === 'blob') { + // -> type indicates that the data is Binary and binary type is "blob" + // a new Blob object, created in the relevant Realm of the WebSocket + // object, that represents data as its raw data + dataForEvent = new Blob([data]) + } else { + // -> type indicates that the data is Binary and binary type is "arraybuffer" + // a new ArrayBuffer object, created in the relevant Realm of the + // WebSocket object, whose contents are data + dataForEvent = new Uint8Array(data).buffer + } + } + + // 3. Fire an event named message at the WebSocket object, using MessageEvent, + // with the origin attribute initialized to the serialization of the WebSocket + // object’s url's origin, and the data attribute initialized to dataForEvent. + fireEvent('message', ws, MessageEvent, { + origin: ws[kWebSocketURL].origin, + data: dataForEvent + }) +} + +/** + * @see https://datatracker.ietf.org/doc/html/rfc6455 + * @see https://datatracker.ietf.org/doc/html/rfc2616 + * @see https://bugs.chromium.org/p/chromium/issues/detail?id=398407 + * @param {string} protocol + */ +function isValidSubprotocol (protocol) { + // If present, this value indicates one + // or more comma-separated subprotocol the client wishes to speak, + // ordered by preference. The elements that comprise this value + // MUST be non-empty strings with characters in the range U+0021 to + // U+007E not including separator characters as defined in + // [RFC2616] and MUST all be unique strings. + if (protocol.length === 0) { + return false + } + + for (const char of protocol) { + const code = char.charCodeAt(0) + + if ( + code < 0x21 || + code > 0x7E || + char === '(' || + char === ')' || + char === '<' || + char === '>' || + char === '@' || + char === ',' || + char === ';' || + char === ':' || + char === '\\' || + char === '"' || + char === '/' || + char === '[' || + char === ']' || + char === '?' || + char === '=' || + char === '{' || + char === '}' || + code === 32 || // SP + code === 9 // HT + ) { + return false + } + } + + return true +} + +/** + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7-4 + * @param {number} code + */ +function isValidStatusCode (code) { + if (code >= 1000 && code < 1015) { + return ( + code !== 1004 && // reserved + code !== 1005 && // "MUST NOT be set as a status code" + code !== 1006 // "MUST NOT be set as a status code" + ) + } + + return code >= 3000 && code <= 4999 +} + +/** + * @param {import('./websocket').WebSocket} ws + * @param {string|undefined} reason + */ +function failWebsocketConnection (ws, reason) { + const { [kController]: controller, [kResponse]: response } = ws + + controller.abort() + + if (response?.socket && !response.socket.destroyed) { + response.socket.destroy() + } + + if (reason) { + fireEvent('error', ws, ErrorEvent, { + error: new Error(reason) + }) + } +} + +module.exports = { + isEstablished, + isClosing, + isClosed, + fireEvent, + isValidSubprotocol, + isValidStatusCode, + failWebsocketConnection, + websocketMessageReceived +} diff --git a/lib/websocket/websocket.js b/lib/websocket/websocket.js new file mode 100644 index 0000000..e4aa58f --- /dev/null +++ b/lib/websocket/websocket.js @@ -0,0 +1,641 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') +const { DOMException } = require('../fetch/constants') +const { URLSerializer } = require('../fetch/dataURL') +const { getGlobalOrigin } = require('../fetch/global') +const { staticPropertyDescriptors, states, opcodes, emptyBuffer } = require('./constants') +const { + kWebSocketURL, + kReadyState, + kController, + kBinaryType, + kResponse, + kSentClose, + kByteParser +} = require('./symbols') +const { isEstablished, isClosing, isValidSubprotocol, failWebsocketConnection, fireEvent } = require('./util') +const { establishWebSocketConnection } = require('./connection') +const { WebsocketFrameSend } = require('./frame') +const { ByteParser } = require('./receiver') +const { kEnumerableProperty, isBlobLike } = require('../core/util') +const { getGlobalDispatcher } = require('../global') +const { types } = require('util') + +let experimentalWarned = false + +// https://websockets.spec.whatwg.org/#interface-definition +class WebSocket extends EventTarget { + #events = { + open: null, + error: null, + close: null, + message: null + } + + #bufferedAmount = 0 + #protocol = '' + #extensions = '' + + /** + * @param {string} url + * @param {string|string[]} protocols + */ + constructor (url, protocols = []) { + super() + + webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket constructor' }) + + if (!experimentalWarned) { + experimentalWarned = true + process.emitWarning('WebSockets are experimental, expect them to change at any time.', { + code: 'UNDICI-WS' + }) + } + + const options = webidl.converters['DOMString or sequence or WebSocketInit'](protocols) + + url = webidl.converters.USVString(url) + protocols = options.protocols + + // 1. Let baseURL be this's relevant settings object's API base URL. + const baseURL = getGlobalOrigin() + + // 1. Let urlRecord be the result of applying the URL parser to url with baseURL. + let urlRecord + + try { + urlRecord = new URL(url, baseURL) + } catch (e) { + // 3. If urlRecord is failure, then throw a "SyntaxError" DOMException. + throw new DOMException(e, 'SyntaxError') + } + + // 4. If urlRecord’s scheme is "http", then set urlRecord’s scheme to "ws". + if (urlRecord.protocol === 'http:') { + urlRecord.protocol = 'ws:' + } else if (urlRecord.protocol === 'https:') { + // 5. Otherwise, if urlRecord’s scheme is "https", set urlRecord’s scheme to "wss". + urlRecord.protocol = 'wss:' + } + + // 6. If urlRecord’s scheme is not "ws" or "wss", then throw a "SyntaxError" DOMException. + if (urlRecord.protocol !== 'ws:' && urlRecord.protocol !== 'wss:') { + throw new DOMException( + `Expected a ws: or wss: protocol, got ${urlRecord.protocol}`, + 'SyntaxError' + ) + } + + // 7. If urlRecord’s fragment is non-null, then throw a "SyntaxError" + // DOMException. + if (urlRecord.hash || urlRecord.href.endsWith('#')) { + throw new DOMException('Got fragment', 'SyntaxError') + } + + // 8. If protocols is a string, set protocols to a sequence consisting + // of just that string. + if (typeof protocols === 'string') { + protocols = [protocols] + } + + // 9. If any of the values in protocols occur more than once or otherwise + // fail to match the requirements for elements that comprise the value + // of `Sec-WebSocket-Protocol` fields as defined by The WebSocket + // protocol, then throw a "SyntaxError" DOMException. + if (protocols.length !== new Set(protocols.map(p => p.toLowerCase())).size) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + if (protocols.length > 0 && !protocols.every(p => isValidSubprotocol(p))) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + // 10. Set this's url to urlRecord. + this[kWebSocketURL] = new URL(urlRecord.href) + + // 11. Let client be this's relevant settings object. + + // 12. Run this step in parallel: + + // 1. Establish a WebSocket connection given urlRecord, protocols, + // and client. + this[kController] = establishWebSocketConnection( + urlRecord, + protocols, + this, + (response) => this.#onConnectionEstablished(response), + options + ) + + // Each WebSocket object has an associated ready state, which is a + // number representing the state of the connection. Initially it must + // be CONNECTING (0). + this[kReadyState] = WebSocket.CONNECTING + + // The extensions attribute must initially return the empty string. + + // The protocol attribute must initially return the empty string. + + // Each WebSocket object has an associated binary type, which is a + // BinaryType. Initially it must be "blob". + this[kBinaryType] = 'blob' + } + + /** + * @see https://websockets.spec.whatwg.org/#dom-websocket-close + * @param {number|undefined} code + * @param {string|undefined} reason + */ + close (code = undefined, reason = undefined) { + webidl.brandCheck(this, WebSocket) + + if (code !== undefined) { + code = webidl.converters['unsigned short'](code, { clamp: true }) + } + + if (reason !== undefined) { + reason = webidl.converters.USVString(reason) + } + + // 1. If code is present, but is neither an integer equal to 1000 nor an + // integer in the range 3000 to 4999, inclusive, throw an + // "InvalidAccessError" DOMException. + if (code !== undefined) { + if (code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException('invalid code', 'InvalidAccessError') + } + } + + let reasonByteLength = 0 + + // 2. If reason is present, then run these substeps: + if (reason !== undefined) { + // 1. Let reasonBytes be the result of encoding reason. + // 2. If reasonBytes is longer than 123 bytes, then throw a + // "SyntaxError" DOMException. + reasonByteLength = Buffer.byteLength(reason) + + if (reasonByteLength > 123) { + throw new DOMException( + `Reason must be less than 123 bytes; received ${reasonByteLength}`, + 'SyntaxError' + ) + } + } + + // 3. Run the first matching steps from the following list: + if (this[kReadyState] === WebSocket.CLOSING || this[kReadyState] === WebSocket.CLOSED) { + // If this's ready state is CLOSING (2) or CLOSED (3) + // Do nothing. + } else if (!isEstablished(this)) { + // If the WebSocket connection is not yet established + // Fail the WebSocket connection and set this's ready state + // to CLOSING (2). + failWebsocketConnection(this, 'Connection was closed before it was established.') + this[kReadyState] = WebSocket.CLOSING + } else if (!isClosing(this)) { + // If the WebSocket closing handshake has not yet been started + // Start the WebSocket closing handshake and set this's ready + // state to CLOSING (2). + // - If neither code nor reason is present, the WebSocket Close + // message must not have a body. + // - If code is present, then the status code to use in the + // WebSocket Close message must be the integer given by code. + // - If reason is also present, then reasonBytes must be + // provided in the Close message after the status code. + + const frame = new WebsocketFrameSend() + + // If neither code nor reason is present, the WebSocket Close + // message must not have a body. + + // If code is present, then the status code to use in the + // WebSocket Close message must be the integer given by code. + if (code !== undefined && reason === undefined) { + frame.frameData = Buffer.allocUnsafe(2) + frame.frameData.writeUInt16BE(code, 0) + } else if (code !== undefined && reason !== undefined) { + // If reason is also present, then reasonBytes must be + // provided in the Close message after the status code. + frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength) + frame.frameData.writeUInt16BE(code, 0) + // the body MAY contain UTF-8-encoded data with value /reason/ + frame.frameData.write(reason, 2, 'utf-8') + } else { + frame.frameData = emptyBuffer + } + + /** @type {import('stream').Duplex} */ + const socket = this[kResponse].socket + + socket.write(frame.createFrame(opcodes.CLOSE), (err) => { + if (!err) { + this[kSentClose] = true + } + }) + + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + this[kReadyState] = states.CLOSING + } else { + // Otherwise + // Set this's ready state to CLOSING (2). + this[kReadyState] = WebSocket.CLOSING + } + } + + /** + * @see https://websockets.spec.whatwg.org/#dom-websocket-send + * @param {NodeJS.TypedArray|ArrayBuffer|Blob|string} data + */ + send (data) { + webidl.brandCheck(this, WebSocket) + + webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket.send' }) + + data = webidl.converters.WebSocketSendData(data) + + // 1. If this's ready state is CONNECTING, then throw an + // "InvalidStateError" DOMException. + if (this[kReadyState] === WebSocket.CONNECTING) { + throw new DOMException('Sent before connected.', 'InvalidStateError') + } + + // 2. Run the appropriate set of steps from the following list: + // https://datatracker.ietf.org/doc/html/rfc6455#section-6.1 + // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + + if (!isEstablished(this) || isClosing(this)) { + return + } + + /** @type {import('stream').Duplex} */ + const socket = this[kResponse].socket + + // If data is a string + if (typeof data === 'string') { + // If the WebSocket connection is established and the WebSocket + // closing handshake has not yet started, then the user agent + // must send a WebSocket Message comprised of the data argument + // using a text frame opcode; if the data cannot be sent, e.g. + // because it would need to be buffered but the buffer is full, + // the user agent must flag the WebSocket as full and then close + // the WebSocket connection. Any invocation of this method with a + // string argument that does not throw an exception must increase + // the bufferedAmount attribute by the number of bytes needed to + // express the argument as UTF-8. + + const value = Buffer.from(data) + const frame = new WebsocketFrameSend(value) + const buffer = frame.createFrame(opcodes.TEXT) + + this.#bufferedAmount += value.byteLength + socket.write(buffer, () => { + this.#bufferedAmount -= value.byteLength + }) + } else if (types.isArrayBuffer(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need + // to be buffered but the buffer is full, the user agent must flag + // the WebSocket as full and then close the WebSocket connection. + // The data to be sent is the data stored in the buffer described + // by the ArrayBuffer object. Any invocation of this method with an + // ArrayBuffer argument that does not throw an exception must + // increase the bufferedAmount attribute by the length of the + // ArrayBuffer in bytes. + + const value = Buffer.from(data) + const frame = new WebsocketFrameSend(value) + const buffer = frame.createFrame(opcodes.BINARY) + + this.#bufferedAmount += value.byteLength + socket.write(buffer, () => { + this.#bufferedAmount -= value.byteLength + }) + } else if (ArrayBuffer.isView(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need to + // be buffered but the buffer is full, the user agent must flag the + // WebSocket as full and then close the WebSocket connection. The + // data to be sent is the data stored in the section of the buffer + // described by the ArrayBuffer object that data references. Any + // invocation of this method with this kind of argument that does + // not throw an exception must increase the bufferedAmount attribute + // by the length of data’s buffer in bytes. + + const ab = Buffer.from(data, data.byteOffset, data.byteLength) + + const frame = new WebsocketFrameSend(ab) + const buffer = frame.createFrame(opcodes.BINARY) + + this.#bufferedAmount += ab.byteLength + socket.write(buffer, () => { + this.#bufferedAmount -= ab.byteLength + }) + } else if (isBlobLike(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need to + // be buffered but the buffer is full, the user agent must flag the + // WebSocket as full and then close the WebSocket connection. The data + // to be sent is the raw data represented by the Blob object. Any + // invocation of this method with a Blob argument that does not throw + // an exception must increase the bufferedAmount attribute by the size + // of the Blob object’s raw data, in bytes. + + const frame = new WebsocketFrameSend() + + data.arrayBuffer().then((ab) => { + const value = Buffer.from(ab) + frame.frameData = value + const buffer = frame.createFrame(opcodes.BINARY) + + this.#bufferedAmount += value.byteLength + socket.write(buffer, () => { + this.#bufferedAmount -= value.byteLength + }) + }) + } + } + + get readyState () { + webidl.brandCheck(this, WebSocket) + + // The readyState getter steps are to return this's ready state. + return this[kReadyState] + } + + get bufferedAmount () { + webidl.brandCheck(this, WebSocket) + + return this.#bufferedAmount + } + + get url () { + webidl.brandCheck(this, WebSocket) + + // The url getter steps are to return this's url, serialized. + return URLSerializer(this[kWebSocketURL]) + } + + get extensions () { + webidl.brandCheck(this, WebSocket) + + return this.#extensions + } + + get protocol () { + webidl.brandCheck(this, WebSocket) + + return this.#protocol + } + + get onopen () { + webidl.brandCheck(this, WebSocket) + + return this.#events.open + } + + set onopen (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.open) { + this.removeEventListener('open', this.#events.open) + } + + if (typeof fn === 'function') { + this.#events.open = fn + this.addEventListener('open', fn) + } else { + this.#events.open = null + } + } + + get onerror () { + webidl.brandCheck(this, WebSocket) + + return this.#events.error + } + + set onerror (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.error) { + this.removeEventListener('error', this.#events.error) + } + + if (typeof fn === 'function') { + this.#events.error = fn + this.addEventListener('error', fn) + } else { + this.#events.error = null + } + } + + get onclose () { + webidl.brandCheck(this, WebSocket) + + return this.#events.close + } + + set onclose (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.close) { + this.removeEventListener('close', this.#events.close) + } + + if (typeof fn === 'function') { + this.#events.close = fn + this.addEventListener('close', fn) + } else { + this.#events.close = null + } + } + + get onmessage () { + webidl.brandCheck(this, WebSocket) + + return this.#events.message + } + + set onmessage (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.message) { + this.removeEventListener('message', this.#events.message) + } + + if (typeof fn === 'function') { + this.#events.message = fn + this.addEventListener('message', fn) + } else { + this.#events.message = null + } + } + + get binaryType () { + webidl.brandCheck(this, WebSocket) + + return this[kBinaryType] + } + + set binaryType (type) { + webidl.brandCheck(this, WebSocket) + + if (type !== 'blob' && type !== 'arraybuffer') { + this[kBinaryType] = 'blob' + } else { + this[kBinaryType] = type + } + } + + /** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + */ + #onConnectionEstablished (response) { + // processResponse is called when the "response’s header list has been received and initialized." + // once this happens, the connection is open + this[kResponse] = response + + const parser = new ByteParser(this) + parser.on('drain', function onParserDrain () { + this.ws[kResponse].socket.resume() + }) + + response.socket.ws = this + this[kByteParser] = parser + + // 1. Change the ready state to OPEN (1). + this[kReadyState] = states.OPEN + + // 2. Change the extensions attribute’s value to the extensions in use, if + // it is not the null value. + // https://datatracker.ietf.org/doc/html/rfc6455#section-9.1 + const extensions = response.headersList.get('sec-websocket-extensions') + + if (extensions !== null) { + this.#extensions = extensions + } + + // 3. Change the protocol attribute’s value to the subprotocol in use, if + // it is not the null value. + // https://datatracker.ietf.org/doc/html/rfc6455#section-1.9 + const protocol = response.headersList.get('sec-websocket-protocol') + + if (protocol !== null) { + this.#protocol = protocol + } + + // 4. Fire an event named open at the WebSocket object. + fireEvent('open', this) + } +} + +// https://websockets.spec.whatwg.org/#dom-websocket-connecting +WebSocket.CONNECTING = WebSocket.prototype.CONNECTING = states.CONNECTING +// https://websockets.spec.whatwg.org/#dom-websocket-open +WebSocket.OPEN = WebSocket.prototype.OPEN = states.OPEN +// https://websockets.spec.whatwg.org/#dom-websocket-closing +WebSocket.CLOSING = WebSocket.prototype.CLOSING = states.CLOSING +// https://websockets.spec.whatwg.org/#dom-websocket-closed +WebSocket.CLOSED = WebSocket.prototype.CLOSED = states.CLOSED + +Object.defineProperties(WebSocket.prototype, { + CONNECTING: staticPropertyDescriptors, + OPEN: staticPropertyDescriptors, + CLOSING: staticPropertyDescriptors, + CLOSED: staticPropertyDescriptors, + url: kEnumerableProperty, + readyState: kEnumerableProperty, + bufferedAmount: kEnumerableProperty, + onopen: kEnumerableProperty, + onerror: kEnumerableProperty, + onclose: kEnumerableProperty, + close: kEnumerableProperty, + onmessage: kEnumerableProperty, + binaryType: kEnumerableProperty, + send: kEnumerableProperty, + extensions: kEnumerableProperty, + protocol: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'WebSocket', + writable: false, + enumerable: false, + configurable: true + } +}) + +Object.defineProperties(WebSocket, { + CONNECTING: staticPropertyDescriptors, + OPEN: staticPropertyDescriptors, + CLOSING: staticPropertyDescriptors, + CLOSED: staticPropertyDescriptors +}) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.DOMString +) + +webidl.converters['DOMString or sequence'] = function (V) { + if (webidl.util.Type(V) === 'Object' && Symbol.iterator in V) { + return webidl.converters['sequence'](V) + } + + return webidl.converters.DOMString(V) +} + +// This implements the propsal made in https://github.com/whatwg/websockets/issues/42 +webidl.converters.WebSocketInit = webidl.dictionaryConverter([ + { + key: 'protocols', + converter: webidl.converters['DOMString or sequence'], + get defaultValue () { + return [] + } + }, + { + key: 'dispatcher', + converter: (V) => V, + get defaultValue () { + return getGlobalDispatcher() + } + }, + { + key: 'headers', + converter: webidl.nullableConverter(webidl.converters.HeadersInit) + } +]) + +webidl.converters['DOMString or sequence or WebSocketInit'] = function (V) { + if (webidl.util.Type(V) === 'Object' && !(Symbol.iterator in V)) { + return webidl.converters.WebSocketInit(V) + } + + return { protocols: webidl.converters['DOMString or sequence'](V) } +} + +webidl.converters.WebSocketSendData = function (V) { + if (webidl.util.Type(V) === 'Object') { + if (isBlobLike(V)) { + return webidl.converters.Blob(V, { strict: false }) + } + + if (ArrayBuffer.isView(V) || types.isAnyArrayBuffer(V)) { + return webidl.converters.BufferSource(V) + } + } + + return webidl.converters.USVString(V) +} + +module.exports = { + WebSocket +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..65a2d98 --- /dev/null +++ b/package.json @@ -0,0 +1,167 @@ +{ + "name": "undici", + "version": "5.28.4", + "description": "An HTTP/1.1 client, written from scratch for Node.js", + "homepage": "https://undici.nodejs.org", + "bugs": { + "url": "https://github.com/nodejs/undici/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/undici.git" + }, + "license": "MIT", + "contributors": [ + { + "name": "Daniele Belardi", + "url": "https://github.com/dnlup", + "author": true + }, + { + "name": "Ethan Arrowood", + "url": "https://github.com/ethan-arrowood", + "author": true + }, + { + "name": "Matteo Collina", + "url": "https://github.com/mcollina", + "author": true + }, + { + "name": "Matthew Aitken", + "url": "https://github.com/KhafraDev", + "author": true + }, + { + "name": "Robert Nagy", + "url": "https://github.com/ronag", + "author": true + }, + { + "name": "Szymon Marczak", + "url": "https://github.com/szmarczak", + "author": true + }, + { + "name": "Tomas Della Vedova", + "url": "https://github.com/delvedor", + "author": true + } + ], + "keywords": [ + "fetch", + "http", + "https", + "promise", + "request", + "curl", + "wget", + "xhr", + "whatwg" + ], + "main": "index.js", + "types": "index.d.ts", + "files": [ + "*.d.ts", + "index.js", + "index-fetch.js", + "lib", + "types", + "docs" + ], + "scripts": { + "build:node": "npx esbuild@0.19.4 index-fetch.js --bundle --platform=node --outfile=undici-fetch.js --define:esbuildDetection=1 --keep-names", + "prebuild:wasm": "node build/wasm.js --prebuild", + "build:wasm": "node build/wasm.js --docker", + "lint": "standard | snazzy", + "lint:fix": "standard --fix | snazzy", + "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript", + "test:cookies": "node scripts/verifyVersion 16 || tap test/cookie/*.js", + "test:node-fetch": "node scripts/verifyVersion.js 16 || mocha --exit test/node-fetch", + "test:fetch": "node scripts/verifyVersion.js 16 || (npm run build:node && tap --expose-gc test/fetch/*.js && tap test/webidl/*.js)", + "test:jest": "node scripts/verifyVersion.js 14 || jest", + "test:tap": "tap test/*.js test/diagnostics-channel/*.js", + "test:tdd": "tap test/*.js test/diagnostics-channel/*.js -w", + "test:typescript": "node scripts/verifyVersion.js 14 || tsd && tsc --skipLibCheck test/imports/undici-import.ts", + "test:websocket": "node scripts/verifyVersion.js 18 || tap test/websocket/*.js", + "test:wpt": "node scripts/verifyVersion 18 || (node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs)", + "coverage": "nyc --reporter=text --reporter=html npm run test", + "coverage:ci": "nyc --reporter=lcov npm run test", + "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run", + "bench:server": "node benchmarks/server.js", + "prebench:run": "node benchmarks/wait.js", + "bench:run": "CONNECTIONS=1 node benchmarks/benchmark.js; CONNECTIONS=50 node benchmarks/benchmark.js", + "serve:website": "docsify serve .", + "prepare": "husky install", + "fuzz": "jsfuzz test/fuzzing/fuzz.js corpus" + }, + "devDependencies": { + "@sinonjs/fake-timers": "^11.1.0", + "@types/node": "^18.0.3", + "abort-controller": "^3.0.0", + "atomic-sleep": "^1.0.0", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "chai-iterator": "^3.0.2", + "chai-string": "^1.5.0", + "concurrently": "^8.0.1", + "cronometro": "^1.0.5", + "delay": "^5.0.0", + "dns-packet": "^5.4.0", + "docsify-cli": "^4.4.3", + "form-data": "^4.0.0", + "formdata-node": "^4.3.1", + "https-pem": "^3.0.0", + "husky": "^8.0.1", + "import-fresh": "^3.3.0", + "jest": "^29.0.2", + "jsdom": "^23.0.0", + "jsfuzz": "^1.0.15", + "mocha": "^10.0.0", + "mockttp": "^3.9.2", + "p-timeout": "^3.2.0", + "pre-commit": "^1.2.2", + "proxy": "^1.0.2", + "proxyquire": "^2.1.3", + "semver": "^7.5.4", + "sinon": "^17.0.1", + "snazzy": "^9.0.0", + "standard": "^17.0.0", + "table": "^6.8.0", + "tap": "^16.1.0", + "tsd": "^0.29.0", + "typescript": "^5.0.2", + "wait-on": "^7.0.1", + "ws": "^8.11.0" + }, + "engines": { + "node": ">=14.0" + }, + "standard": { + "env": [ + "mocha" + ], + "ignore": [ + "lib/llhttp/constants.js", + "lib/llhttp/utils.js", + "test/wpt/tests" + ] + }, + "tsd": { + "directory": "test/types", + "compilerOptions": { + "esModuleInterop": true, + "lib": [ + "esnext" + ] + } + }, + "jest": { + "testMatch": [ + "/test/jest/**" + ] + }, + "dependencies": { + "@fastify/busboy": "^2.0.0" + } +} diff --git a/scripts/generate-pem.js b/scripts/generate-pem.js new file mode 100644 index 0000000..0d7e628 --- /dev/null +++ b/scripts/generate-pem.js @@ -0,0 +1,3 @@ +/* istanbul ignore file */ + +require('https-pem/install') diff --git a/scripts/generate-undici-types-package-json.js b/scripts/generate-undici-types-package-json.js new file mode 100644 index 0000000..78095ae --- /dev/null +++ b/scripts/generate-undici-types-package-json.js @@ -0,0 +1,28 @@ +const fs = require('node:fs') +const path = require('node:path') + +const packageJSONPath = path.join(__dirname, '..', 'package.json') +const packageJSONRaw = fs.readFileSync(packageJSONPath, 'utf-8') +const packageJSON = JSON.parse(packageJSONRaw) + +const licensePath = path.join(__dirname, '..', 'LICENSE') +const licenseRaw = fs.readFileSync(licensePath, 'utf-8') + +const packageTypesJSON = { + name: 'undici-types', + version: packageJSON.version, + description: 'A stand-alone types package for Undici', + homepage: packageJSON.homepage, + bugs: packageJSON.bugs, + repository: packageJSON.repository, + license: packageJSON.license, + types: 'index.d.ts', + files: ['*.d.ts'], + contributors: packageJSON.contributors +} + +const packageTypesPath = path.join(__dirname, '..', 'types', 'package.json') +const licenseTypesPath = path.join(__dirname, '..', 'types', 'LICENSE') + +fs.writeFileSync(packageTypesPath, JSON.stringify(packageTypesJSON, null, 2)) +fs.writeFileSync(licenseTypesPath, licenseRaw) diff --git a/scripts/verifyVersion.js b/scripts/verifyVersion.js new file mode 100644 index 0000000..8ad2d19 --- /dev/null +++ b/scripts/verifyVersion.js @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +const [major, minor, patch] = process.versions.node.split('.').map(v => Number(v)) +const required = process.argv.pop().split('.').map(v => Number(v)) + +const badMajor = major < required[0] +const badMinor = major === required[0] && minor < required[1] +const badPatch = major === required[0] && minor === required[1] && patch < required[2] + +if (badMajor || badMinor || badPatch) { + console.log(`Required Node.js >=${required.join('.')}, got ${process.versions.node}`) + console.log('Skipping') +} else { + process.exit(1) +} diff --git a/test/abort-controller.js b/test/abort-controller.js new file mode 100644 index 0000000..4658686 --- /dev/null +++ b/test/abort-controller.js @@ -0,0 +1,238 @@ +'use strict' + +const { test } = require('tap') +const { AbortController: NPMAbortController } = require('abort-controller') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { createReadStream } = require('fs') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') + +const controllers = [{ + AbortControllerImpl: NPMAbortController, + controllerName: 'npm-abortcontroller-shim' +}] + +if (global.AbortController) { + controllers.push({ + AbortControllerImpl: global.AbortController, + controllerName: 'native-abortcontroller' + }) +} +for (const { AbortControllerImpl, controllerName } of controllers) { + test(`Abort ${controllerName} before creating request`, (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const abortController = new AbortControllerImpl() + t.teardown(client.destroy.bind(client)) + + abortController.abort() + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + + test(`Abort ${controllerName} before sending request (no body)`, (t) => { + t.plan(3) + + let count = 0 + const server = createServer((req, res) => { + if (count === 1) { + t.fail('The second request should never be executed') + } + count += 1 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const abortController = new AbortControllerImpl() + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + + abortController.abort() + }) + }) + + test(`Abort ${controllerName} while waiting response (no body)`, (t) => { + t.plan(1) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + abortController.abort() + res.setHeader('content-type', 'text/plain') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + + test(`Abort ${controllerName} while waiting response (write headers started) (no body)`, (t) => { + t.plan(1) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.flushHeaders() + abortController.abort() + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + + test(`Abort ${controllerName} while waiting response (write headers and write body started) (no body)`, (t) => { + t.plan(2) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.error(err) + response.body.on('data', () => { + abortController.abort() + }) + response.body.on('error', err => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + }) + + function waitingWithBody (body, type) { // eslint-disable-line + test(`Abort ${controllerName} while waiting response (with body ${type})`, (t) => { + t.plan(1) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + abortController.abort() + res.setHeader('content-type', 'text/plain') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + } + + waitingWithBody('hello', 'string') + waitingWithBody(createReadStream(__filename), 'stream') + waitingWithBody(new Uint8Array([42]), 'Uint8Array') + waitingWithBody(wrapWithAsyncIterable(createReadStream(__filename)), 'async-iterator') + + function writeHeadersStartedWithBody (body, type) { // eslint-disable-line + test(`Abort ${controllerName} while waiting response (write headers started) (with body ${type})`, (t) => { + t.plan(1) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.flushHeaders() + abortController.abort() + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + } + + writeHeadersStartedWithBody('hello', 'string') + writeHeadersStartedWithBody(createReadStream(__filename), 'stream') + writeHeadersStartedWithBody(new Uint8Array([42]), 'Uint8Array') + writeHeadersStartedWithBody(wrapWithAsyncIterable(createReadStream(__filename)), 'async-iterator') + + function writeBodyStartedWithBody (body, type) { // eslint-disable-line + test(`Abort ${controllerName} while waiting response (write headers and write body started) (with body ${type})`, (t) => { + t.plan(2) + + const abortController = new AbortControllerImpl() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: abortController.signal }, (err, response) => { + t.error(err) + response.body.on('data', () => { + abortController.abort() + }) + response.body.on('error', err => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + }) + } + + writeBodyStartedWithBody('hello', 'string') + writeBodyStartedWithBody(createReadStream(__filename), 'stream') + writeBodyStartedWithBody(new Uint8Array([42]), 'Uint8Array') + writeBodyStartedWithBody(wrapWithAsyncIterable(createReadStream(__filename), 'async-iterator')) +} diff --git a/test/abort-event-emitter.js b/test/abort-event-emitter.js new file mode 100644 index 0000000..a5397e4 --- /dev/null +++ b/test/abort-event-emitter.js @@ -0,0 +1,259 @@ +'use strict' + +const { test } = require('tap') +const EventEmitter = require('events') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { createReadStream } = require('fs') +const { Readable } = require('stream') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') + +test('Abort before sending request (no body)', (t) => { + t.plan(4) + + let count = 0 + const server = createServer((req, res) => { + if (count === 1) { + t.fail('The second request should never be executed') + } + count += 1 + res.end('hello') + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const ee = new EventEmitter() + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + const body = new Readable({ read () { } }) + body.on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + client.request({ + path: '/', + method: 'GET', + signal: ee, + body + }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + + ee.emit('abort') + }) +}) + +test('Abort before sending request (no body) async iterator', (t) => { + t.plan(3) + + let count = 0 + const server = createServer((req, res) => { + if (count === 1) { + t.fail('The second request should never be executed') + } + count += 1 + res.end('hello') + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const ee = new EventEmitter() + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + const body = wrapWithAsyncIterable(new Readable({ read () { } })) + client.request({ + path: '/', + method: 'GET', + signal: ee, + body + }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + + ee.emit('abort') + }) +}) + +test('Abort while waiting response (no body)', (t) => { + t.plan(1) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + ee.emit('abort') + res.setHeader('content-type', 'text/plain') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) +}) + +test('Abort while waiting response (write headers started) (no body)', (t) => { + t.plan(1) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.flushHeaders() + ee.emit('abort') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) +}) + +test('Abort while waiting response (write headers and write body started) (no body)', (t) => { + t.plan(2) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => { + t.error(err) + response.body.on('data', () => { + ee.emit('abort') + }) + response.body.on('error', err => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) +}) + +function waitingWithBody (body, type) { + test(`Abort while waiting response (with body ${type})`, (t) => { + t.plan(1) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + ee.emit('abort') + res.setHeader('content-type', 'text/plain') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: ee }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) +} + +waitingWithBody('hello', 'string') +waitingWithBody(createReadStream(__filename), 'stream') +waitingWithBody(new Uint8Array([42]), 'Uint8Array') +waitingWithBody(wrapWithAsyncIterable(createReadStream(__filename)), 'async-iterator') + +function writeHeadersStartedWithBody (body, type) { + test(`Abort while waiting response (write headers started) (with body ${type})`, (t) => { + t.plan(1) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.flushHeaders() + ee.emit('abort') + res.end('hello world') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: ee }, (err, response) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) +} + +writeHeadersStartedWithBody('hello', 'string') +writeHeadersStartedWithBody(createReadStream(__filename), 'stream') +writeHeadersStartedWithBody(new Uint8Array([42]), 'Uint8Array') +writeHeadersStartedWithBody(wrapWithAsyncIterable(createReadStream(__filename)), 'async-iterator') + +function writeBodyStartedWithBody (body, type) { + test(`Abort while waiting response (write headers and write body started) (with body ${type})`, (t) => { + t.plan(2) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'POST', body, signal: ee }, (err, response) => { + t.error(err) + response.body.on('data', () => { + ee.emit('abort') + }) + response.body.on('error', err => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) + }) +} + +writeBodyStartedWithBody('hello', 'string') +writeBodyStartedWithBody(createReadStream(__filename), 'stream') +writeBodyStartedWithBody(new Uint8Array([42]), 'Uint8Array') +writeBodyStartedWithBody(wrapWithAsyncIterable(createReadStream(__filename)), 'async-iterator') diff --git a/test/agent.js b/test/agent.js new file mode 100644 index 0000000..65afd8b --- /dev/null +++ b/test/agent.js @@ -0,0 +1,782 @@ +'use strict' + +const { test, teardown } = require('tap') +const http = require('http') +const { PassThrough } = require('stream') +const { kRunning } = require('../lib/core/symbols') +const { + Agent, + errors, + request, + stream, + pipeline, + Pool, + setGlobalDispatcher, + getGlobalDispatcher +} = require('../') +const importFresh = require('import-fresh') + +test('setGlobalDispatcher', t => { + t.plan(2) + + t.test('fails if agent does not implement `get` method', t => { + t.plan(1) + t.throws(() => setGlobalDispatcher({ dispatch: 'not a function' }), errors.InvalidArgumentError) + }) + + t.test('sets global agent', t => { + t.plan(2) + t.doesNotThrow(() => setGlobalDispatcher(new Agent())) + t.doesNotThrow(() => setGlobalDispatcher({ dispatch: () => {} })) + }) + + t.teardown(() => { + // reset globalAgent to a fresh Agent instance for later tests + setGlobalDispatcher(new Agent()) + }) +}) + +test('Agent', t => { + t.plan(1) + + t.doesNotThrow(() => new Agent()) +}) + +test('agent should call callback after closing internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.pass('first request should resolve') + }) + .catch(err => { + t.fail(err) + }) + + dispatcher.once('connect', () => { + dispatcher.close(() => { + request(origin, { dispatcher }) + .then(() => { + t.fail('second request should not resolve') + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +}) + +test('agent close throws when callback is not a function', t => { + t.plan(1) + const dispatcher = new Agent() + try { + dispatcher.close({}) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('agent should close internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.pass('first request should resolve') + }) + .catch(err => { + t.fail(err) + }) + + dispatcher.once('connect', () => { + dispatcher.close() + .then(() => request(origin, { dispatcher })) + .then(() => { + t.fail('second request should not resolve') + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) +}) + +test('agent should destroy internal pools and call callback', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + + dispatcher.once('connect', () => { + dispatcher.destroy(() => { + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +}) + +test('agent destroy throws when callback is not a function', t => { + t.plan(1) + const dispatcher = new Agent() + try { + dispatcher.destroy(new Error('mock error'), {}) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('agent close/destroy callback with error', t => { + t.plan(4) + const dispatcher = new Agent() + t.equal(dispatcher.closed, false) + dispatcher.close() + t.equal(dispatcher.closed, true) + t.equal(dispatcher.destroyed, false) + dispatcher.destroy(new Error('mock error')) + t.equal(dispatcher.destroyed, true) +}) + +test('agent should destroy internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + + dispatcher.once('connect', () => { + dispatcher.destroy() + .then(() => request(origin, { dispatcher })) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) +}) + +test('multiple connections', t => { + const connections = 3 + t.plan(6 * connections) + + const server = http.createServer((req, res) => { + res.writeHead(200, { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=1s' + }) + res.end('ok') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + const dispatcher = new Agent({ connections }) + + t.teardown(dispatcher.close.bind(dispatcher)) + + dispatcher.on('connect', (origin, [dispatcher]) => { + t.ok(dispatcher) + }) + dispatcher.on('disconnect', (origin, [dispatcher], error) => { + t.ok(dispatcher) + t.type(error, errors.InformationalError) + t.equal(error.code, 'UND_ERR_INFO') + t.equal(error.message, 'reset') + }) + + for (let i = 0; i < connections; i++) { + await request(origin, { dispatcher }) + .then(() => { + t.pass('should pass') + }) + .catch(err => { + t.fail(err) + }) + } + }) +}) + +test('agent factory supports URL parameter', (t) => { + t.plan(2) + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const dispatcher = new Agent({ + factory: (origin, opts) => { + t.ok(origin instanceof URL) + return new Pool(origin, opts) + } + }) + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: new URL(`http://localhost:${server.address().port}`), + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('agent factory supports string parameter', (t) => { + t.plan(2) + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const dispatcher = new Agent({ + factory: (origin, opts) => { + t.ok(typeof origin === 'string') + return new Pool(origin, opts) + } + }) + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: `http://localhost:${server.address().port}`, + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + request(`http://localhost:${server.address().port}`) + .then(({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + }) + .catch(err => { + t.fail(err) + }) + }) +}) + +test('with local agent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent({ + connect: { + servername: 'agent1' + } + }) + + server.listen(0, () => { + request(`http://localhost:${server.address().port}`, { dispatcher }) + .then(({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + }) + .catch(err => { + t.fail(err) + }) + }) +}) + +test('fails with invalid args', t => { + t.throws(() => request(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => request(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => request({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => request({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') + t.throws(() => request('https://example.com', { path: 0 }), errors.InvalidArgumentError, 'throws on opts.path argument') + t.throws(() => request('https://example.com', { agent: new Agent() }), errors.InvalidArgumentError, 'throws on opts.path argument') + t.throws(() => request('https://example.com', 'asd'), errors.InvalidArgumentError, 'throws on non object opts argument') + t.end() +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + stream( + `http://localhost:${server.address().port}`, + { + opaque: new PassThrough() + }, + ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + pt.on('error', () => { + t.fail() + }) + return pt + } + ) + }) +}) + +test('with a local agent', t => { + t.plan(9) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent() + + dispatcher.on('connect', (origin, [dispatcher]) => { + t.ok(dispatcher) + t.equal(dispatcher[kRunning], 0) + process.nextTick(() => { + t.equal(dispatcher[kRunning], 1) + }) + }) + + server.listen(0, () => { + stream( + `http://localhost:${server.address().port}`, + { + dispatcher, + opaque: new PassThrough() + }, + ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + pt.on('error', () => { + t.fail() + }) + return pt + } + ) + }) +}) + +test('stream: fails with invalid URL', t => { + t.plan(4) + t.throws(() => stream(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => stream(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => stream({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => stream({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const bufs = [] + + pipeline( + `http://localhost:${server.address().port}`, + {}, + ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + } + ) + .end() + .on('data', buf => { + bufs.push(buf) + }) + .on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + .on('error', () => { + t.fail() + }) + }) +}) + +test('with a local agent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent() + + server.listen(0, () => { + const bufs = [] + + pipeline( + `http://localhost:${server.address().port}`, + { dispatcher }, + ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + } + ) + .end() + .on('data', buf => { + bufs.push(buf) + }) + .on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + .on('error', () => { + t.fail() + }) + }) +}) + +test('pipeline: fails with invalid URL', t => { + t.plan(4) + t.throws(() => pipeline(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => pipeline(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => pipeline({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => pipeline({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') +}) + +test('pipeline: fails with invalid onInfo', (t) => { + t.plan(2) + pipeline({ origin: 'http://localhost', path: '/', onInfo: 'foo' }, () => {}).on('error', (err) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid onInfo callback') + }) +}) + +test('request: fails with invalid onInfo', async (t) => { + try { + await request({ origin: 'http://localhost', path: '/', onInfo: 'foo' }) + t.fail('should throw') + } catch (e) { + t.ok(e) + t.equal(e.message, 'invalid onInfo callback') + } + t.end() +}) + +test('stream: fails with invalid onInfo', async (t) => { + try { + await stream({ origin: 'http://localhost', path: '/', onInfo: 'foo' }, () => new PassThrough()) + t.fail('should throw') + } catch (e) { + t.ok(e) + t.equal(e.message, 'invalid onInfo callback') + } + t.end() +}) + +test('constructor validations', t => { + t.plan(4) + t.throws(() => new Agent({ factory: 'ASD' }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: 'ASD' }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: -1 }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: null }), errors.InvalidArgumentError, 'throws on invalid opts argument') +}) + +test('dispatch validations', t => { + const dispatcher = new Agent() + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + t.plan(6) + t.throws(() => dispatcher.dispatch('ASD'), errors.InvalidArgumentError, 'throws on missing handler') + t.throws(() => dispatcher.dispatch('ASD', noopHandler), errors.InvalidArgumentError, 'throws on invalid opts argument type') + t.throws(() => dispatcher.dispatch({}, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') + t.throws(() => dispatcher.dispatch({ origin: '' }, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') + t.throws(() => dispatcher.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler.onError') + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: new URL(`http://localhost:${server.address().port}`), + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('drain', t => { + t.plan(2) + + const dispatcher = new Agent({ + connections: 1, + pipelining: 1 + }) + + dispatcher.on('drain', () => { + t.pass() + }) + + class Handler { + onConnect () {} + onHeaders () {} + onData () {} + onComplete () {} + onError () { + t.fail() + } + } + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + t.equal(dispatcher.dispatch({ + origin: `http://localhost:${server.address().port}`, + method: 'GET', + path: '/' + }, new Handler()), false) + }) +}) + +test('global api', t => { + t.plan(6 * 2) + + const server = http.createServer((req, res) => { + if (req.url === '/bar') { + t.equal(req.method, 'PUT') + t.equal(req.url, '/bar') + } else { + t.equal(req.method, 'GET') + t.equal(req.url, '/foo') + } + req.pipe(res) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + await request(origin, { path: '/foo' }) + await request(`${origin}/foo`) + await request({ origin, path: '/foo' }) + await stream({ origin, path: '/foo' }, () => new PassThrough()) + await request({ protocol: 'http:', hostname: 'localhost', port: server.address().port, path: '/foo' }) + await request(`${origin}/bar`, { body: 'asd' }) + }) +}) + +test('global api throws', t => { + const origin = 'http://asd' + t.throws(() => request(`${origin}/foo`, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin, path: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin, pathname: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request(0), errors.InvalidArgumentError) + t.throws(() => request(1), errors.InvalidArgumentError) + t.end() +}) + +test('unreachable request rejects and can be caught', t => { + t.plan(1) + + request('https://thisis.not/avalid/url').catch(() => { + t.pass() + }) +}) + +test('connect is not valid', t => { + t.plan(1) + + t.throws(() => new Agent({ connect: false }), errors.InvalidArgumentError, 'connect must be a function or an object') +}) + +test('the dispatcher is truly global', t => { + const agent = getGlobalDispatcher() + const undiciFresh = importFresh('../index.js') + t.equal(agent, undiciFresh.getGlobalDispatcher()) + t.end() +}) + +teardown(() => process.exit()) diff --git a/test/async_hooks.js b/test/async_hooks.js new file mode 100644 index 0000000..2e8533d --- /dev/null +++ b/test/async_hooks.js @@ -0,0 +1,206 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { createHook, executionAsyncId } = require('async_hooks') +const { readFile } = require('fs') +const { PassThrough } = require('stream') + +const transactions = new Map() + +function getCurrentTransaction () { + const asyncId = executionAsyncId() + return transactions.has(asyncId) ? transactions.get(asyncId) : null +} + +function setCurrentTransaction (trans) { + const asyncId = executionAsyncId() + transactions.set(asyncId, trans) +} + +const hook = createHook({ + init (asyncId, type, triggerAsyncId, resource) { + if (type === 'TIMERWRAP') return + // process._rawDebug(type + ' ' + asyncId) + transactions.set(asyncId, getCurrentTransaction()) + }, + destroy (asyncId) { + transactions.delete(asyncId) + } +}) + +hook.enable() + +test('async hooks', (t) => { + t.plan(31) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + readFile(__filename, (err, buf) => { + t.error(err) + const buf1 = buf.slice(0, buf.length / 2) + const buf2 = buf.slice(buf.length / 2) + // we split the file so that it's received in 2 chunks + // and it should restore the state on the second + res.write(buf1) + setTimeout(() => { + res.end(buf2) + }, 10) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + body.resume() + t.strictSame(getCurrentTransaction(), null) + + setCurrentTransaction({ hello: 'world2' }) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.strictSame(getCurrentTransaction(), { hello: 'world2' }) + + body.once('data', () => { + t.pass() + body.resume() + }) + + body.on('end', () => { + t.pass() + }) + }) + }) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + body.resume() + t.strictSame(getCurrentTransaction(), null) + + setCurrentTransaction({ hello: 'world' }) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.strictSame(getCurrentTransaction(), { hello: 'world' }) + + body.once('data', () => { + t.pass() + body.resume() + }) + + body.on('end', () => { + t.pass() + }) + }) + }) + + client.request({ path: '/', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + body.resume() + t.strictSame(getCurrentTransaction(), null) + + setCurrentTransaction({ hello: 'world' }) + + client.request({ path: '/', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.strictSame(getCurrentTransaction(), { hello: 'world' }) + + body.once('data', () => { + t.pass() + body.resume() + }) + + body.on('end', () => { + t.pass() + }) + }) + }) + + client.stream({ path: '/', method: 'GET' }, () => { + t.strictSame(getCurrentTransaction(), null) + return new PassThrough().resume() + }, (err) => { + t.error(err) + t.strictSame(getCurrentTransaction(), null) + + setCurrentTransaction({ hello: 'world' }) + + client.stream({ path: '/', method: 'GET' }, () => { + t.strictSame(getCurrentTransaction(), { hello: 'world' }) + return new PassThrough().resume() + }, (err) => { + t.error(err) + t.strictSame(getCurrentTransaction(), { hello: 'world' }) + }) + }) + }) +}) + +test('async hooks client is destroyed', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + readFile(__filename, (err, buf) => { + t.error(err) + res.write('asd') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', throwOnError: true }, (err, { body }) => { + t.error(err) + body.resume() + body.on('error', (err) => { + t.ok(err) + }) + t.strictSame(getCurrentTransaction(), null) + + setCurrentTransaction({ hello: 'world2' }) + + client.request({ path: '/', method: 'GET' }, (err) => { + t.equal(err.message, 'The client is destroyed') + t.strictSame(getCurrentTransaction(), { hello: 'world2' }) + }) + client.destroy((err) => { + t.error(err) + }) + }) + }) +}) + +test('async hooks pipeline handler', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + setCurrentTransaction({ hello: 'world2' }) + + client + .pipeline({ path: '/', method: 'GET' }, ({ body }) => { + t.strictSame(getCurrentTransaction(), { hello: 'world2' }) + return body + }) + .on('close', () => { + t.pass() + }) + .resume() + .end() + }) +}) diff --git a/test/autoselectfamily.js b/test/autoselectfamily.js new file mode 100644 index 0000000..0b44a3e --- /dev/null +++ b/test/autoselectfamily.js @@ -0,0 +1,198 @@ +'use strict' + +const { test, skip } = require('tap') +const dgram = require('dgram') +const { Resolver } = require('dns') +const dnsPacket = require('dns-packet') +const { createServer } = require('http') +const { Client, Agent, request } = require('..') +const { nodeHasAutoSelectFamily } = require('../lib/core/util') + +/* + * IMPORTANT + * + * As only some version of Node have autoSelectFamily enabled by default (>= 20), make sure the option is always + * explicitly passed in tests in this file to avoid compatibility problems across release lines. + * + */ + +if (!nodeHasAutoSelectFamily) { + skip('autoSelectFamily is not supportee') + process.exit() +} + +function _lookup (resolver, hostname, options, cb) { + resolver.resolve(hostname, 'ANY', (err, replies) => { + if (err) { + return cb(err) + } + + const hosts = replies + .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 })) + .sort((a, b) => b.family - a.family) + + if (options.all === true) { + return cb(null, hosts) + } + + return cb(null, hosts[0].address, hosts[0].family) + }) +} + +function createDnsServer (ipv6Addr, ipv4Addr, cb) { + // Create a DNS server which replies with a AAAA and a A record for the same host + const socket = dgram.createSocket('udp4') + + socket.on('message', (msg, { address, port }) => { + const parsed = dnsPacket.decode(msg) + + const response = dnsPacket.encode({ + type: 'answer', + id: parsed.id, + questions: parsed.questions, + answers: [ + { type: 'AAAA', class: 'IN', name: 'example.org', data: '::1', ttl: 123 }, + { type: 'A', class: 'IN', name: 'example.org', data: '127.0.0.1', ttl: 123 } + ] + }) + + socket.send(response, port, address) + }) + + socket.bind(0, () => { + const resolver = new Resolver() + resolver.setServers([`127.0.0.1:${socket.address().port}`]) + + cb(null, { dnsServer: socket, lookup: _lookup.bind(null, resolver) }) + }) +} + +test('with autoSelectFamily enable the request succeeds when using request', (t) => { + t.plan(3) + + createDnsServer('::1', '127.0.0.1', function (_, { dnsServer, lookup }) { + const server = createServer((req, res) => { + res.end('hello') + }) + + t.teardown(() => { + server.close() + dnsServer.close() + }) + + server.listen(0, '127.0.0.1', () => { + const agent = new Agent({ connect: { lookup }, autoSelectFamily: true }) + + request( + `http://example.org:${server.address().port}/`, { + method: 'GET', + dispatcher: agent + }, (err, { statusCode, body }) => { + t.error(err) + + let response = Buffer.alloc(0) + + body.on('data', chunk => { + response = Buffer.concat([response, chunk]) + }) + + body.on('end', () => { + t.strictSame(statusCode, 200) + t.strictSame(response.toString('utf-8'), 'hello') + }) + }) + }) + }) +}) + +test('with autoSelectFamily enable the request succeeds when using a client', (t) => { + t.plan(3) + + createDnsServer('::1', '127.0.0.1', function (_, { dnsServer, lookup }) { + const server = createServer((req, res) => { + res.end('hello') + }) + + t.teardown(() => { + server.close() + dnsServer.close() + }) + + server.listen(0, '127.0.0.1', () => { + const client = new Client(`http://example.org:${server.address().port}`, { connect: { lookup }, autoSelectFamily: true }) + + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { statusCode, body }) => { + t.error(err) + + let response = Buffer.alloc(0) + + body.on('data', chunk => { + response = Buffer.concat([response, chunk]) + }) + + body.on('end', () => { + t.strictSame(statusCode, 200) + t.strictSame(response.toString('utf-8'), 'hello') + }) + }) + }) + }) +}) + +test('with autoSelectFamily disabled the request fails when using request', (t) => { + t.plan(1) + + createDnsServer('::1', '127.0.0.1', function (_, { dnsServer, lookup }) { + const server = createServer((req, res) => { + res.end('hello') + }) + + t.teardown(() => { + server.close() + dnsServer.close() + }) + + server.listen(0, '127.0.0.1', () => { + const agent = new Agent({ connect: { lookup, autoSelectFamily: false } }) + + request(`http://example.org:${server.address().port}`, { + method: 'GET', + dispatcher: agent + }, (err, { statusCode, body }) => { + t.ok(['ECONNREFUSED', 'EAFNOSUPPORT'].includes(err.code)) + }) + }) + }) +}) + +test('with autoSelectFamily disabled the request fails when using a client', (t) => { + t.plan(1) + + createDnsServer('::1', '127.0.0.1', function (_, { dnsServer, lookup }) { + const server = createServer((req, res) => { + res.end('hello') + }) + + t.teardown(() => { + server.close() + dnsServer.close() + }) + + server.listen(0, '127.0.0.1', () => { + const client = new Client(`http://example.org:${server.address().port}`, { connect: { lookup, autoSelectFamily: false } }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { statusCode, body }) => { + t.ok(['ECONNREFUSED', 'EAFNOSUPPORT'].includes(err.code)) + }) + }) + }) +}) diff --git a/test/balanced-pool.js b/test/balanced-pool.js new file mode 100644 index 0000000..297f207 --- /dev/null +++ b/test/balanced-pool.js @@ -0,0 +1,565 @@ +'use strict' + +const { test } = require('tap') +const { BalancedPool, Pool, Client, errors } = require('..') +const { createServer } = require('http') +const { promisify } = require('util') + +test('throws when factory is not a function', (t) => { + t.plan(2) + + try { + new BalancedPool(null, { factory: '' }) // eslint-disable-line + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'factory must be a function.') + } +}) + +test('add/remove upstreams', (t) => { + t.plan(7) + + const upstream01 = 'http://localhost:1' + const upstream02 = 'http://localhost:2' + + const pool = new BalancedPool() + t.same(pool.upstreams, []) + + // try to remove non-existent upstream + pool.removeUpstream(upstream01) + t.same(pool.upstreams, []) + + pool.addUpstream(upstream01) + t.same(pool.upstreams, [upstream01]) + + // try to add the same upstream + pool.addUpstream(upstream01) + t.same(pool.upstreams, [upstream01]) + + pool.addUpstream(upstream02) + t.same(pool.upstreams, [upstream01, upstream02]) + + pool.removeUpstream(upstream02) + t.same(pool.upstreams, [upstream01]) + + pool.removeUpstream(upstream01) + t.same(pool.upstreams, []) +}) + +test('basic get', async (t) => { + t.plan(16) + + let server1Called = 0 + const server1 = createServer((req, res) => { + server1Called++ + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server1.close.bind(server1)) + + await promisify(server1.listen).call(server1, 0) + + let server2Called = 0 + const server2 = createServer((req, res) => { + server2Called++ + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server2.close.bind(server2)) + + await promisify(server2.listen).call(server2, 0) + + const client = new BalancedPool() + client.addUpstream(`http://localhost:${server1.address().port}`) + client.addUpstream(`http://localhost:${server2.address().port}`) + t.teardown(client.destroy.bind(client)) + + { + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + t.equal('hello', await body.text()) + } + + { + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + t.equal('hello', await body.text()) + } + + t.equal(server1Called, 1) + t.equal(server2Called, 1) + + t.equal(client.destroyed, false) + t.equal(client.closed, false) + await client.close() + t.equal(client.destroyed, true) + t.equal(client.closed, true) +}) + +test('connect/disconnect event(s)', (t) => { + const clients = 2 + + t.plan(clients * 5) + + const server = createServer((req, res) => { + res.writeHead(200, { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=1s' + }) + res.end('ok') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const pool = new BalancedPool(`http://localhost:${server.address().port}`, { + connections: clients, + keepAliveTimeoutThreshold: 100 + }) + t.teardown(pool.close.bind(pool)) + + pool.on('connect', (origin, [pool, pool2, client]) => { + t.equal(client instanceof Client, true) + }) + pool.on('disconnect', (origin, [pool, pool2, client], error) => { + t.ok(client instanceof Client) + t.type(error, errors.InformationalError) + t.equal(error.code, 'UND_ERR_INFO') + }) + + for (let i = 0; i < clients; i++) { + pool.request({ + path: '/', + method: 'GET' + }, (err, { headers, body }) => { + t.error(err) + body.resume() + }) + } + }) +}) + +test('busy', (t) => { + t.plan(8 * 6 + 2 + 1) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new BalancedPool(`http://localhost:${server.address().port}`, { + connections: 2, + pipelining: 2 + }) + client.on('drain', () => { + t.pass() + }) + client.on('connect', () => { + t.pass() + }) + t.teardown(client.destroy.bind(client)) + + for (let n = 1; n <= 8; ++n) { + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + } + }) +}) + +test('factory option with basic get request', async (t) => { + t.plan(12) + + let factoryCalled = 0 + const opts = { + factory: (origin, opts) => { + factoryCalled++ + return new Pool(origin, opts) + } + } + + const client = new BalancedPool([], opts) // eslint-disable-line + + let serverCalled = 0 + const server = createServer((req, res) => { + serverCalled++ + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen).call(server, 0) + + client.addUpstream(`http://localhost:${server.address().port}`) + + t.same(client.upstreams, [`http://localhost:${server.address().port}`]) + + t.teardown(client.destroy.bind(client)) + + { + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + t.equal('hello', await body.text()) + } + + t.equal(serverCalled, 1) + t.equal(factoryCalled, 1) + + t.equal(client.destroyed, false) + t.equal(client.closed, false) + await client.close() + t.equal(client.destroyed, true) + t.equal(client.closed, true) +}) + +test('throws when upstream is missing', async (t) => { + t.plan(2) + + const pool = new BalancedPool() + + try { + await pool.request({ path: '/', method: 'GET' }) + } catch (e) { + t.type(e, errors.BalancedPoolMissingUpstreamError) + t.equal(e.message, 'No upstream has been added to the BalancedPool') + } +}) + +class TestServer { + constructor ({ config: { server, socketHangup, downOnRequests, socketHangupOnRequests }, onRequest }) { + this.config = { + downOnRequests: downOnRequests || [], + socketHangupOnRequests: socketHangupOnRequests || [], + socketHangup + } + this.name = server + // start a server listening to any port available on the host + this.port = 0 + this.iteration = 0 + this.requestsCount = 0 + this.onRequest = onRequest + this.server = null + } + + _shouldHangupOnClient () { + if (this.config.socketHangup) { + return true + } + if (this.config.socketHangupOnRequests.includes(this.requestsCount)) { + return true + } + + return false + } + + _shouldStopServer () { + if (this.config.upstreamDown === true || this.config.downOnRequests.includes(this.requestsCount)) { + return true + } + return false + } + + async prepareForIteration (iteration) { + // set current iteration + this.iteration = iteration + + if (this._shouldStopServer()) { + await this.stop() + } else if (!this.isRunning()) { + await this.start() + } + } + + start () { + this.server = createServer((req, res) => { + if (this._shouldHangupOnClient()) { + req.destroy(new Error('(ツ)')) + return + } + this.requestsCount++ + res.end('server is running!') + + this.onRequest(this) + }).listen(this.port) + + this.server.keepAliveTimeout = 2000 + + return new Promise((resolve) => { + this.server.on('listening', () => { + // store the used port to use it again if the server was stopped as part of test and then started again + this.port = this.server.address().port + + return resolve() + }) + }) + } + + isRunning () { + return !!this.server.address() + } + + stop () { + if (!this.isRunning()) { + return + } + + return new Promise(resolve => { + this.server.close(() => resolve()) + }) + } +} + +const cases = [ + + // 0 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 7, + config: [{ server: 'A' }, { server: 'B' }, { server: 'C' }], + expected: ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 0, + expectedSocketErrors: 0, + expectedRatios: [0.34, 0.33, 0.33] + }, + + // 1 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', downOnRequests: [0] }, { server: 'B' }, { server: 'C' }], + expected: ['A/connectionRefused', 'B', 'C', 'B', 'C', 'B', 'C', 'A', 'B', 'C', 'A'], + expectedConnectionRefusedErrors: 1, + expectedSocketErrors: 0, + expectedRatios: [0.32, 0.34, 0.34] + }, + + // 2 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A' }, { server: 'B', downOnRequests: [0] }, { server: 'C' }], + expected: ['A', 'B/connectionRefused', 'C', 'A', 'C', 'A', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 1, + expectedSocketErrors: 0, + expectedRatios: [0.34, 0.32, 0.34] + }, + + // 3 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A' }, { server: 'B', downOnRequests: [0] }, { server: 'C', downOnRequests: [0] }], + expected: ['A', 'B/connectionRefused', 'C/connectionRefused', 'A', 'A', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 2, + expectedSocketErrors: 0, + expectedRatios: [0.35, 0.33, 0.32] + }, + + // 4 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', downOnRequests: [0] }, { server: 'B', downOnRequests: [0] }, { server: 'C', downOnRequests: [0] }], + expected: ['A/connectionRefused', 'B/connectionRefused', 'C/connectionRefused', 'A', 'B', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 3, + expectedSocketErrors: 0, + expectedRatios: [0.34, 0.33, 0.33] + }, + + // 5 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', downOnRequests: [0, 1, 2] }, { server: 'B', downOnRequests: [0, 1, 2] }, { server: 'C', downOnRequests: [0, 1, 2] }], + expected: ['A/connectionRefused', 'B/connectionRefused', 'C/connectionRefused', 'A/connectionRefused', 'B/connectionRefused', 'C/connectionRefused', 'A/connectionRefused', 'B/connectionRefused', 'C/connectionRefused', 'A', 'B', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 9, + expectedSocketErrors: 0, + expectedRatios: [0.34, 0.33, 0.33] + }, + + // 6 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', downOnRequests: [0] }, { server: 'B', downOnRequests: [0, 1] }, { server: 'C', downOnRequests: [0] }], + expected: ['A/connectionRefused', 'B/connectionRefused', 'C/connectionRefused', 'A', 'B/connectionRefused', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A', 'C', 'A', 'C', 'A', 'C', 'A', 'B'], + expectedConnectionRefusedErrors: 4, + expectedSocketErrors: 0, + expectedRatios: [0.36, 0.29, 0.35] + }, + + // 7 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A' }, { server: 'B' }, { server: 'C', downOnRequests: [1] }], + expected: ['A', 'B', 'C', 'A', 'B', 'C/connectionRefused', 'A', 'B', 'A', 'B', 'A', 'B', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 1, + expectedSocketErrors: 0, + expectedRatios: [0.34, 0.34, 0.32], + + // Skip because the behavior of Node.js has changed + skip: true + }, + + // 8 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', socketHangupOnRequests: [1] }, { server: 'B' }, { server: 'C' }], + expected: ['A', 'B', 'C', 'A/socketError', 'B', 'C', 'B', 'C', 'B', 'C', 'A'], + expectedConnectionRefusedErrors: 0, + expectedSocketErrors: 1, + expectedRatios: [0.32, 0.34, 0.34] + }, + + // 9 + + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 7, + config: [{ server: 'A' }, { server: 'B' }, { server: 'C' }, { server: 'D' }, { server: 'E' }], + expected: ['A', 'B', 'C', 'D', 'E', 'A', 'B', 'C', 'D', 'E'], + expectedConnectionRefusedErrors: 0, + expectedSocketErrors: 0, + expectedRatios: [0.2, 0.2, 0.2, 0.2, 0.2] + }, + + // 10 + { + iterations: 100, + maxWeightPerServer: 100, + errorPenalty: 15, + config: [{ server: 'A', downOnRequests: [0, 1, 2, 3] }, { server: 'B' }, { server: 'C' }], + expected: ['A/connectionRefused', 'B', 'C', 'B', 'C', 'B', 'C', 'A/connectionRefused', 'B', 'C', 'B', 'C', 'A/connectionRefused', 'B', 'C', 'B', 'C', 'A/connectionRefused', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C'], + expectedConnectionRefusedErrors: 4, + expectedSocketErrors: 0, + expectedRatios: [0.18, 0.41, 0.41] + } + +] + +for (const [index, { config, expected, expectedRatios, iterations = 9, expectedConnectionRefusedErrors = 0, expectedSocketErrors = 0, maxWeightPerServer, errorPenalty = 10, only = false, skip = false }] of cases.entries()) { + test(`weighted round robin - case ${index}`, { only, skip }, async (t) => { + // cerate an array to store succesfull reqeusts + const requestLog = [] + + // create instances of the test servers according to the config + const servers = config.map((serverConfig) => new TestServer({ + config: serverConfig, + onRequest: (server) => { + requestLog.push(server.name) + } + })) + t.teardown(() => servers.map(server => server.stop())) + + // start all servers to get a port so that we can build the upstream urls to supply them to undici + await Promise.all(servers.map(server => server.start())) + + // build upstream urls + const urls = servers.map(server => `http://localhost:${server.port}`) + + // add upstreams + const client = new BalancedPool(urls[0], { maxWeightPerServer, errorPenalty }) + urls.slice(1).map(url => client.addUpstream(url)) + + let connectionRefusedErrors = 0 + let socketErrors = 0 + for (let i = 0; i < iterations; i++) { + // setup test servers for the next iteration + + await Promise.all(servers.map(server => server.prepareForIteration(i))) + + // send a request using undinci + try { + await client.request({ path: '/', method: 'GET' }) + } catch (e) { + const serverWithError = + servers.find(server => server.port === e.port) || + servers.find(server => { + if (typeof AggregateError === 'function' && e instanceof AggregateError) { + return e.errors.some(e => server.port === (e.socket?.remotePort ?? e.port)) + } + + return server.port === e.socket.remotePort + }) + + serverWithError.requestsCount++ + + if (e.code === 'ECONNREFUSED') { + requestLog.push(`${serverWithError.name}/connectionRefused`) + connectionRefusedErrors++ + } + if (e.code === 'UND_ERR_SOCKET') { + requestLog.push(`${serverWithError.name}/socketError`) + + socketErrors++ + } + } + } + const totalRequests = servers.reduce((acc, server) => { + return acc + server.requestsCount + }, 0) + + t.equal(totalRequests, iterations) + + t.equal(connectionRefusedErrors, expectedConnectionRefusedErrors) + t.equal(socketErrors, expectedSocketErrors) + + if (expectedRatios) { + const ratios = servers.reduce((acc, el) => { + acc[el.name] = 0 + return acc + }, {}) + requestLog.map(el => ratios[el[0]]++) + + t.match(Object.keys(ratios).map(k => ratios[k] / iterations), expectedRatios) + } + + if (expected) { + t.match(requestLog.slice(0, expected.length), expected) + } + + await client.close() + }) +} diff --git a/test/ca-fingerprint.js b/test/ca-fingerprint.js new file mode 100644 index 0000000..f71063f --- /dev/null +++ b/test/ca-fingerprint.js @@ -0,0 +1,126 @@ +'use strict' + +const crypto = require('crypto') +const https = require('https') +const { test } = require('tap') +const { Client, buildConnector } = require('..') +const pem = require('https-pem') + +const caFingerprint = getFingerprint(pem.cert.toString() + .split('\n') + .slice(1, -1) + .map(line => line.trim()) + .join('') +) + +test('Validate CA fingerprint with a custom connector', t => { + t.plan(2) + + const server = https.createServer(pem, (req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + + server.listen(0, function () { + const connector = buildConnector({ rejectUnauthorized: false }) + const client = new Client(`https://localhost:${server.address().port}`, { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) { + socket.destroy() + cb(new Error('Fingerprint does not match')) + } else { + cb(null, socket) + } + }) + } + }) + + t.teardown(() => { + client.close() + server.close() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) + +test('Bad CA fingerprint with a custom connector', t => { + t.plan(2) + + const server = https.createServer(pem, (req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + + server.listen(0, function () { + const connector = buildConnector({ rejectUnauthorized: false }) + const client = new Client(`https://localhost:${server.address().port}`, { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== 'FO:OB:AR') { + socket.destroy() + cb(new Error('Fingerprint does not match')) + } else { + cb(null, socket) + } + }) + } + }) + + t.teardown(() => { + client.close() + server.close() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.equal(err.message, 'Fingerprint does not match') + t.equal(data.body, undefined) + }) + }) +}) + +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} + +function getFingerprint (content, inputEncoding = 'base64', outputEncoding = 'hex') { + const shasum = crypto.createHash('sha256') + shasum.update(content, inputEncoding) + const res = shasum.digest(outputEncoding) + return res.toUpperCase().match(/.{1,2}/g).join(':') +} diff --git a/test/client-abort.js b/test/client-abort.js new file mode 100644 index 0000000..5854bc2 --- /dev/null +++ b/test/client-abort.js @@ -0,0 +1,213 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') + +class OnAbortError extends Error {} + +test('aborted response errors', (t) => { + t.plan(3) + + const server = createServer() + server.once('request', (req, res) => { + // TODO: res.write will cause body to emit 'error' twice + // due to bug in readable-stream. + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + body.destroy() + body + .on('error', err => { + t.type(err, errors.RequestAbortedError) + }) + .on('close', () => { + t.pass() + }) + }) + }) +}) + +test('aborted req', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4 + 1, 'a')) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'POST', + path: '/', + body: new Readable({ + read () { + setImmediate(() => { + this.destroy() + }) + } + }) + }, (err) => { + t.type(err, errors.RequestAbortedError) + }) + }) +}) + +test('abort', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.dispatch({ + method: 'GET', + path: '/' + }, { + onConnect (abort) { + setImmediate(abort) + }, + onHeaders () { + t.fail() + }, + onData () { + t.fail() + }, + onComplete () { + t.fail() + }, + onError (err) { + t.type(err, errors.RequestAbortedError) + } + }) + + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('abort pipelined', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + let counter = 0 + client.dispatch({ + method: 'GET', + path: '/' + }, { + onConnect (abort) { + // This request will be retried + if (counter++ === 1) { + abort() + } + t.pass() + }, + onHeaders () { + t.fail() + }, + onData () { + t.fail() + }, + onComplete () { + t.fail() + }, + onError (err) { + t.type(err, errors.RequestAbortedError) + } + }) + + client.dispatch({ + method: 'GET', + path: '/' + }, { + onConnect (abort) { + abort() + }, + onHeaders () { + t.fail() + }, + onData () { + t.fail() + }, + onComplete () { + t.fail() + }, + onError (err) { + t.type(err, errors.RequestAbortedError) + } + }) + + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('propagate unallowed throws in request.onError', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.dispatch({ + method: 'GET', + path: '/' + }, { + onConnect (abort) { + setImmediate(abort) + }, + onHeaders () { + t.pass() + }, + onData () { + t.pass() + }, + onComplete () { + t.pass() + }, + onError () { + throw new OnAbortError('error') + } + }) + + client.on('error', (err) => { + t.type(err, OnAbortError) + }) + + client.on('disconnect', () => { + t.pass() + }) + }) +}) diff --git a/test/client-connect.js b/test/client-connect.js new file mode 100644 index 0000000..7c8ca5e --- /dev/null +++ b/test/client-connect.js @@ -0,0 +1,308 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const http = require('http') +const EE = require('events') +const { kBusy } = require('../lib/core/symbols') + +test('basic connect', (t) => { + t.plan(3) + + const server = http.createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = firstBodyChunk.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const signal = new EE() + const promise = client.connect({ + signal, + path: '/' + }) + t.equal(signal.listenerCount('abort'), 1) + const { socket } = await promise + t.equal(signal.listenerCount('abort'), 0) + + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.equal(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }) +}) + +test('connect error', (t) => { + t.plan(1) + + const server = http.createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + await client.connect({ + path: '/' + }) + } catch (err) { + t.ok(err) + } + }) +}) + +test('connect invalid opts', (t) => { + t.plan(6) + + const client = new Client('http://localhost:5432') + + client.connect(null, err => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid opts') + }) + + try { + client.connect(null, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid opts') + } + + try { + client.connect({ path: '/' }, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid callback') + } +}) + +test('connect wait for empty pipeline', (t) => { + t.plan(7) + + let canConnect = false + const server = http.createServer((req, res) => { + res.end() + canConnect = true + }) + server.on('connect', (req, socket, firstBodyChunk) => { + t.equal(canConnect, true) + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = firstBodyChunk.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + client.once('connect', () => { + process.nextTick(() => { + t.equal(client[kBusy], false) + + client.connect({ + path: '/' + }, (err, { socket }) => { + t.error(err) + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.equal(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }) + t.equal(client[kBusy], true) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + }) + }) + }) +}) + +test('connect aborted', (t) => { + t.plan(6) + + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('connect', (req, c, firstBodyChunk) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + const signal = new EE() + client.connect({ + path: '/', + signal, + opaque: 'asd' + }, (err, { opaque }) => { + t.equal(opaque, 'asd') + t.equal(signal.listenerCount('abort'), 0) + t.type(err, errors.RequestAbortedError) + }) + t.equal(client[kBusy], true) + t.equal(signal.listenerCount('abort'), 1) + signal.emit('abort') + + client.close(() => { + t.pass() + }) + }) +}) + +test('basic connect error', (t) => { + t.plan(2) + + const server = http.createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = firstBodyChunk.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.connect({ + path: '/' + }, (err, { socket }) => { + t.error(err) + socket.on('error', (err) => { + t.equal(err, _err) + }) + throw _err + }) + }) +}) + +test('connect invalid signal', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('connect', (req, c, firstBodyChunk) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.on('disconnect', () => { + t.fail() + }) + + client.connect({ + path: '/', + signal: 'error', + opaque: 'asd' + }, (err, { opaque }) => { + t.equal(opaque, 'asd') + t.type(err, errors.InvalidArgumentError) + }) + }) +}) + +test('connect aborted after connect', (t) => { + t.plan(3) + + const signal = new EE() + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('connect', (req, c, firstBodyChunk) => { + signal.emit('abort') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + client.connect({ + path: '/', + signal, + opaque: 'asd' + }, (err, { opaque }) => { + t.equal(opaque, 'asd') + t.type(err, errors.RequestAbortedError) + }) + t.equal(client[kBusy], true) + }) +}) diff --git a/test/client-dispatch.js b/test/client-dispatch.js new file mode 100644 index 0000000..c3de37a --- /dev/null +++ b/test/client-dispatch.js @@ -0,0 +1,815 @@ +'use strict' + +const { test } = require('tap') +const http = require('http') +const { Client, Pool, errors } = require('..') +const stream = require('stream') + +test('dispatch invalid opts', (t) => { + t.plan(14) + + const client = new Client('http://localhost:5000') + + try { + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 1 + }, null) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'handler must be an object') + } + + try { + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 1 + }, 'asd') + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'handler must be an object') + } + + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 1 + }, { + onError (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'upgrade must be a string') + } + }) + + client.dispatch({ + path: '/', + method: 'GET', + headersTimeout: 'asd' + }, { + onError (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid headersTimeout') + } + }) + + client.dispatch({ + path: '/', + method: 'GET', + bodyTimeout: 'asd' + }, { + onError (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid bodyTimeout') + } + }) + + client.dispatch({ + origin: 'another', + path: '/', + method: 'GET', + bodyTimeout: 'asd' + }, { + onError (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid bodyTimeout') + } + }) + + client.dispatch(null, { + onError (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'opts must be an object.') + } + }) +}) + +test('basic dispatch get', (t) => { + t.plan(11) + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(undefined, req.headers.foo) + t.equal('bar', req.headers.bar) + t.equal('', req.headers.baz) + t.equal(undefined, req.headers['content-length']) + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const reqHeaders = { + foo: undefined, + bar: 'bar', + baz: null + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const bufs = [] + client.dispatch({ + path: '/', + method: 'GET', + headers: reqHeaders + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + t.equal(Array.isArray(headers), true) + }, + onData (buf) { + bufs.push(buf) + }, + onComplete (trailers) { + t.same(trailers, []) + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }, + onError () { + t.fail() + } + }) + }) +}) + +test('trailers dispatch get', (t) => { + t.plan(12) + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(undefined, req.headers.foo) + t.equal('bar', req.headers.bar) + t.equal(undefined, req.headers['content-length']) + res.addTrailers({ 'Content-MD5': 'test' }) + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Trailer', 'Content-MD5') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const bufs = [] + client.dispatch({ + path: '/', + method: 'GET', + headers: reqHeaders + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + t.equal(Array.isArray(headers), true) + { + const contentTypeIdx = headers.findIndex(x => x.toString() === 'Content-Type') + t.equal(headers[contentTypeIdx + 1].toString(), 'text/plain') + } + }, + onData (buf) { + bufs.push(buf) + }, + onComplete (trailers) { + t.equal(Array.isArray(trailers), true) + { + const contentMD5Idx = trailers.findIndex(x => x.toString() === 'Content-MD5') + t.equal(trailers[contentMD5Idx + 1].toString(), 'test') + } + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }, + onError () { + t.fail() + } + }) + }) +}) + +test('dispatch onHeaders error', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + throw _err + }, + onData (buf) { + t.fail() + }, + onComplete (trailers) { + t.fail() + }, + onError (err) { + t.equal(err, _err) + } + }) + }) +}) + +test('dispatch onComplete error', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.pass() + }, + onData (buf) { + t.fail() + }, + onComplete (trailers) { + throw _err + }, + onError (err) { + t.equal(err, _err) + } + }) + }) +}) + +test('dispatch onData error', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.pass() + }, + onData (buf) { + throw _err + }, + onComplete (trailers) { + t.fail() + }, + onError (err) { + t.equal(err, _err) + } + }) + }) +}) + +test('dispatch onConnect error', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + throw _err + }, + onHeaders (statusCode, headers) { + t.fail() + }, + onData (buf) { + t.fail() + }, + onComplete (trailers) { + t.fail() + }, + onError (err) { + t.equal(err, _err) + } + }) + }) +}) + +test('connect call onUpgrade once', (t) => { + t.plan(2) + + const server = http.createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = firstBodyChunk.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let recvData = '' + let count = 0 + client.dispatch({ + method: 'CONNECT', + path: '/' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.pass('should not throw') + }, + onUpgrade (statusCode, headers, socket) { + t.equal(count++, 0) + + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.equal(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }, + onData (buf) { + t.fail() + }, + onComplete (trailers) { + t.fail() + }, + onError () { + t.fail() + } + }) + }) +}) + +test('dispatch onConnect missing', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onHeaders (statusCode, headers) { + t.pass('should not throw') + }, + onData (buf) { + t.pass('should not throw') + }, + onComplete (trailers) { + t.pass('should not throw') + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) + }) +}) + +test('dispatch onHeaders missing', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onData (buf) { + t.fail('should not throw') + }, + onComplete (trailers) { + t.fail('should not throw') + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) + }) +}) + +test('dispatch onData missing', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.fail('should not throw') + }, + onComplete (trailers) { + t.fail('should not throw') + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) + }) +}) + +test('dispatch onComplete missing', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.fail() + }, + onData (buf) { + t.fail() + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) + }) +}) + +test('dispatch onError missing', (t) => { + t.plan(1) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.fail() + }, + onData (buf) { + t.fail() + }, + onComplete (trailers) { + t.fail() + } + }) + } catch (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) +}) + +test('dispatch CONNECT onUpgrade missing', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 'Websocket' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(err.message, 'invalid onUpgrade method') + } + }) + }) +}) + +test('dispatch upgrade onUpgrade missing', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 'Websocket' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(err.message, 'invalid onUpgrade method') + } + }) + }) +}) + +test('dispatch pool onError missing', (t) => { + t.plan(2) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + client.dispatch({ + path: '/', + method: 'GET', + upgrade: 'Websocket' + }, { + }) + } catch (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(err.message, 'invalid onError method') + } + }) +}) + +test('dispatch onBodySent not a function', (t) => { + t.plan(2) + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onBodySent: '42', + onConnect () {}, + onHeaders () {}, + onData () {}, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(err.message, 'invalid onBodySent method') + } + }) + }) +}) + +test('dispatch onBodySent buffer', (t) => { + t.plan(3) + + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + const body = 'hello 🚀' + client.dispatch({ + path: '/', + method: 'POST', + body + }, { + onBodySent (chunk) { + t.equal(chunk.toString(), body) + }, + onRequestSent () { + t.pass() + }, + onError (err) { + throw err + }, + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + t.pass() + } + }) + }) +}) + +test('dispatch onBodySent stream', (t) => { + t.plan(8) + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + const chunks = ['he', 'llo', 'world', '🚀'] + const toSendBytes = chunks.reduce((a, b) => a + Buffer.byteLength(b), 0) + const body = stream.Readable.from(chunks) + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + let sentBytes = 0 + let currentChunk = 0 + client.dispatch({ + path: '/', + method: 'POST', + body + }, { + onBodySent (chunk) { + t.equal(chunks[currentChunk++], chunk) + sentBytes += Buffer.byteLength(chunk) + }, + onRequestSent () { + t.pass() + }, + onError (err) { + throw err + }, + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + t.equal(currentChunk, chunks.length) + t.equal(sentBytes, toSendBytes) + t.pass() + } + }) + }) +}) + +test('dispatch onBodySent async-iterable', (t) => { + const server = http.createServer((req, res) => { + res.end('ad') + }) + t.teardown(server.close.bind(server)) + const chunks = ['he', 'llo', 'world', '🚀'] + const toSendBytes = chunks.reduce((a, b) => a + Buffer.byteLength(b), 0) + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + let sentBytes = 0 + let currentChunk = 0 + client.dispatch({ + path: '/', + method: 'POST', + body: chunks + }, { + onBodySent (chunk) { + t.equal(chunks[currentChunk++], chunk) + sentBytes += Buffer.byteLength(chunk) + }, + onError (err) { + throw err + }, + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + t.equal(currentChunk, chunks.length) + t.equal(sentBytes, toSendBytes) + t.end() + } + }) + }) +}) + +test('dispatch onBodySent throws error', (t) => { + const server = http.createServer((req, res) => { + res.end('ended') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + const body = 'hello' + client.dispatch({ + path: '/', + method: 'POST', + body + }, { + onBodySent (chunk) { + throw new Error('fail') + }, + onError (err) { + t.type(err, Error) + t.equal(err.message, 'fail') + t.end() + }, + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () {} + }) + }) +}) diff --git a/test/client-errors.js b/test/client-errors.js new file mode 100644 index 0000000..cec7f37 --- /dev/null +++ b/test/client-errors.js @@ -0,0 +1,1285 @@ +'use strict' + +const { test } = require('tap') +const { Client, Pool, errors } = require('..') +const { createServer } = require('http') +const https = require('https') +const pem = require('https-pem') +const net = require('net') +const { Readable } = require('stream') + +const { kSocket } = require('../lib/core/symbols') +const { wrapWithAsyncIterable, maybeWrapStream, consts } = require('./utils/async-iterators') + +class IteratorError extends Error {} + +test('GET errors and reconnect with pipelining 1', (t) => { + t.plan(9) + + const server = createServer() + + server.once('request', (req, res) => { + t.pass('first request received, destroying') + res.socket.destroy() + + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', idempotent: false, opaque: 'asd' }, (err, data) => { + t.type(err, Error) // we are expecting an error + t.equal(data.opaque, 'asd') + }) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('GET errors and reconnect with pipelining 3', (t) => { + const server = createServer() + const requestsThatWillError = 3 + let requests = 0 + + t.plan(6 + requestsThatWillError * 3) + + server.on('request', (req, res) => { + if (requests++ < requestsThatWillError) { + t.pass('request received, destroying') + + // socket might not be there if it was destroyed by another + // pipelined request + if (res.socket) { + res.socket.destroy() + } + } else { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + } + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + // all of these will error + for (let i = 0; i < 3; i++) { + client.request({ path: '/', method: 'GET', idempotent: false, opaque: 'asd' }, (err, data) => { + t.type(err, Error) // we are expecting an error + t.equal(data.opaque, 'asd') + }) + } + + // this will be queued up + client.request({ path: '/', method: 'GET', idempotent: false }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +function errorAndPipelining (type) { + test(`POST with a ${type} that errors and pipelining 1 should reconnect`, (t) => { + t.plan(12) + + const server = createServer() + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('POST', req.method) + t.equal('42', req.headers['content-length']) + + const bufs = [] + req.on('data', (buf) => { + bufs.push(buf) + }) + + req.on('aborted', () => { + // we will abruptly close the connection here + // but this will still end + t.equal('a string', Buffer.concat(bufs).toString('utf8')) + }) + + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: { + // higher than the length of the string + 'content-length': 42 + }, + opaque: 'asd', + body: maybeWrapStream(new Readable({ + read () { + this.push('a string') + this.destroy(new Error('kaboom')) + } + }), type) + }, (err, data) => { + t.equal(err.message, 'kaboom') + t.equal(data.opaque, 'asd') + }) + + // this will be queued up + client.request({ path: '/', method: 'GET', idempotent: false }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) +} + +errorAndPipelining(consts.STREAM) +errorAndPipelining(consts.ASYNC_ITERATOR) + +function errorAndChunkedEncodingPipelining (type) { + test(`POST with chunked encoding, ${type} body that errors and pipelining 1 should reconnect`, (t) => { + t.plan(12) + + const server = createServer() + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('POST', req.method) + t.equal(req.headers['content-length'], undefined) + + const bufs = [] + req.on('data', (buf) => { + bufs.push(buf) + }) + + req.on('aborted', () => { + // we will abruptly close the connection here + // but this will still end + t.equal('a string', Buffer.concat(bufs).toString('utf8')) + }) + + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'POST', + opaque: 'asd', + body: maybeWrapStream(new Readable({ + read () { + this.push('a string') + this.destroy(new Error('kaboom')) + } + }), type) + }, (err, data) => { + t.equal(err.message, 'kaboom') + t.equal(data.opaque, 'asd') + }) + + // this will be queued up + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) +} + +errorAndChunkedEncodingPipelining(consts.STREAM) +errorAndChunkedEncodingPipelining(consts.ASYNC_ITERATOR) + +test('invalid options throws', (t) => { + try { + new Client({ port: 'foobar', protocol: 'https:' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'Invalid URL: port must be a valid integer or a string representation of an integer.') + } + + try { + new Client(new URL('http://asd:200/somepath')) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid url') + } + + try { + new Client(new URL('http://asd:200?q=asd')) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid url') + } + + try { + new Client(new URL('http://asd:200#asd')) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid url') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + socketPath: 1 + }) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid socketPath') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + keepAliveTimeout: 'asd' + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid keepAliveTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + localAddress: 123 + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'localAddress must be valid string IP address') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + localAddress: 'abcd123' + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'localAddress must be valid string IP address') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + keepAliveMaxTimeout: 'asd' + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid keepAliveMaxTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + keepAliveMaxTimeout: 0 + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid keepAliveMaxTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + keepAliveTimeoutThreshold: 'asd' + }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid keepAliveTimeoutThreshold') + } + + try { + new Client({ // eslint-disable-line + protocol: 'asd' + }) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + try { + new Client({ // eslint-disable-line + hostname: 1 + }) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + maxHeaderSize: 'asd' + }) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid maxHeaderSize') + } + + try { + new Client(1) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'Invalid URL: The URL argument must be a non-null object.') + } + + try { + const client = new Client(new URL('http://localhost:200')) // eslint-disable-line + client.destroy(null, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid callback') + } + + try { + const client = new Client(new URL('http://localhost:200')) // eslint-disable-line + client.close(null, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid callback') + } + + try { + new Client(new URL('http://localhost:200'), { maxKeepAliveTimeout: 1e3 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'unsupported maxKeepAliveTimeout, use keepAliveMaxTimeout instead') + } + + try { + new Client(new URL('http://localhost:200'), { keepAlive: false }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'unsupported keepAlive, use pipelining=0 instead') + } + + try { + new Client(new URL('http://localhost:200'), { idleTimeout: 30e3 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'unsupported idleTimeout, use keepAliveTimeout instead') + } + + try { + new Client(new URL('http://localhost:200'), { socketTimeout: 30e3 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'unsupported socketTimeout, use headersTimeout & bodyTimeout instead') + } + + try { + new Client(new URL('http://localhost:200'), { requestTimeout: 30e3 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'unsupported requestTimeout, use headersTimeout & bodyTimeout instead') + } + + try { + new Client(new URL('http://localhost:200'), { connectTimeout: -1 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid connectTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { connectTimeout: Infinity }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid connectTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { connectTimeout: 'asd' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid connectTimeout') + } + + try { + new Client(new URL('http://localhost:200'), { connect: 'asd' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'connect must be a function or an object') + } + + try { + new Client(new URL('http://localhost:200'), { connect: -1 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'connect must be a function or an object') + } + + try { + new Pool(new URL('http://localhost:200'), { connect: 'asd' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'connect must be a function or an object') + } + + try { + new Pool(new URL('http://localhost:200'), { connect: -1 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'connect must be a function or an object') + } + + try { + new Client(new URL('http://localhost:200'), { maxCachedSessions: -10 }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'maxCachedSessions must be a positive integer or zero') + } + + try { + new Client(new URL('http://localhost:200'), { maxCachedSessions: 'foo' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'maxCachedSessions must be a positive integer or zero') + } + + try { + new Client(new URL('http://localhost:200'), { maxRequestsPerClient: 'foo' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'maxRequestsPerClient must be a positive number') + } + + try { + new Client(new URL('http://localhost:200'), { autoSelectFamilyAttemptTimeout: 'foo' }) // eslint-disable-line + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'autoSelectFamilyAttemptTimeout must be a positive number') + } + + t.end() +}) + +test('POST which fails should error response', (t) => { + t.plan(6) + + const server = createServer() + server.on('request', (req, res) => { + req.once('data', () => { + res.destroy() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + function checkError (err) { + // Different platforms error with different codes... + t.ok( + err.code === 'EPIPE' || + err.code === 'ECONNRESET' || + err.code === 'UND_ERR_SOCKET' || + err.message === 'other side closed' + ) + } + + { + const body = new Readable({ read () {} }) + body.push('asd') + body.on('error', (err) => { + checkError(err) + }) + + client.request({ + path: '/', + method: 'POST', + body + }, (err) => { + checkError(err) + }) + } + + { + const body = new Readable({ read () {} }) + body.push('asd') + body.on('error', (err) => { + checkError(err) + }) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': 100 + }, + body + }, (err) => { + checkError(err) + }) + } + + { + const body = wrapWithAsyncIterable(['asd'], true) + + client.request({ + path: '/', + method: 'POST', + body + }, (err) => { + checkError(err) + }) + } + + { + const body = wrapWithAsyncIterable(['asd'], true) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': 100 + }, + body + }, (err) => { + checkError(err) + }) + } + }) +}) + +test('client destroy cleanup', (t) => { + t.plan(3) + + const _err = new Error('kaboom') + let client + const server = createServer() + server.once('request', (req, res) => { + req.once('data', () => { + client.destroy(_err, (err) => { + t.error(err) + }) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const body = new Readable({ read () {} }) + body.push('asd') + body.on('error', (err) => { + t.equal(err, _err) + }) + + client.request({ + path: '/', + method: 'POST', + body + }, (err, data) => { + t.equal(err, _err) + }) + }) +}) + +test('throwing async-iterator causes error', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4 + 1, 'a')) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'POST', + path: '/', + body: (async function * () { + yield 'hello' + throw new IteratorError('bad iterator') + })() + }, (err) => { + t.type(err, IteratorError) + }) + }) +}) + +test('client async-iterator destroy cleanup', (t) => { + t.plan(2) + + const _err = new Error('kaboom') + let client + const server = createServer() + server.once('request', (req, res) => { + req.once('data', () => { + client.destroy(_err, (err) => { + t.error(err) + }) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const body = wrapWithAsyncIterable(['asd'], true) + + client.request({ + path: '/', + method: 'POST', + body + }, (err, data) => { + t.equal(err, _err) + }) + }) +}) + +test('GET errors body', (t) => { + t.plan(2) + + const server = createServer() + server.once('request', (req, res) => { + res.write('asd') + setTimeout(() => { + res.destroy() + }, 19) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + body.resume() + body.on('error', err => ( + t.ok(err) + )) + }) + }) +}) + +test('validate request body', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + body: /asdasd/ + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'POST', + body: 0 + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'POST', + body: false + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'POST', + body: '' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + method: 'POST', + body: new Uint8Array() + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + method: 'POST', + body: Buffer.alloc(10) + }, (err, data) => { + t.error(err) + data.body.resume() + }) + }) +}) + +test('parser error', (t) => { + t.plan(2) + + const server = net.createServer() + server.once('connection', (socket) => { + socket.write('asd\n\r213123') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err) + client.close((err) => { + t.error(err) + }) + }) + }) +}) + +function socketFailWrite (type) { + test(`socket fail while writing ${type} request body`, (t) => { + t.plan(2) + + const server = createServer() + server.once('request', (req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const preBody = new Readable({ read () {} }) + preBody.push('asd') + const body = maybeWrapStream(preBody, type) + client.on('connect', () => { + process.nextTick(() => { + client[kSocket].destroy('kaboom') + }) + }) + + client.request({ + path: '/', + method: 'POST', + body + }, (err) => { + t.ok(err) + }) + client.close((err) => { + t.error(err) + }) + }) + }) +} +socketFailWrite(consts.STREAM) +socketFailWrite(consts.ASYNC_ITERATOR) + +function socketFailEndWrite (type) { + test(`socket fail while ending ${type} request body`, (t) => { + t.plan(3) + + const server = createServer() + server.once('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + const _err = new Error('kaboom') + client.on('connect', () => { + process.nextTick(() => { + client[kSocket].destroy(_err) + }) + }) + const preBody = new Readable({ read () {} }) + preBody.push(null) + const body = maybeWrapStream(preBody, type) + + client.request({ + path: '/', + method: 'POST', + body + }, (err) => { + t.equal(err, _err) + }) + client.close((err) => { + t.error(err) + client.close((err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +} + +socketFailEndWrite(consts.STREAM) +socketFailEndWrite(consts.ASYNC_ITERATOR) + +test('queued request should not fail on socket destroy', (t) => { + t.plan(4) + + const server = createServer() + server.on('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('error', () => { + t.pass() + }) + client[kSocket].destroy() + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + t.pass() + }) + }) + }) + }) +}) + +test('queued request should fail on client destroy', (t) => { + t.plan(6) + + const server = createServer() + server.on('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + let requestErrored = false + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + .on('error', () => { + t.pass() + }) + client.destroy((err) => { + t.error(err) + t.equal(requestErrored, true) + }) + }) + client.request({ + path: '/', + method: 'GET', + opaque: 'asd' + }, (err, data) => { + requestErrored = true + t.ok(err) + t.equal(data.opaque, 'asd') + }) + }) +}) + +test('retry idempotent inflight', (t) => { + t.plan(3) + + const server = createServer() + server.on('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + body: new Readable({ + read () { + this.destroy(new Error('kaboom')) + } + }) + }, (err) => { + t.ok(err) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + }) +}) + +test('invalid opts', (t) => { + t.plan(2) + + const client = new Client('http://localhost:5000') + client.request(null, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + client.pipeline(null).on('error', (err) => { + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('default port for http and https', (t) => { + t.plan(4) + + try { + new Client(new URL('http://localhost:80')) // eslint-disable-line + t.pass('Should not throw') + } catch (err) { + t.fail(err) + } + + try { + new Client(new URL('http://localhost')) // eslint-disable-line + t.pass('Should not throw') + } catch (err) { + t.fail(err) + } + + try { + new Client(new URL('https://localhost:443')) // eslint-disable-line + t.pass('Should not throw') + } catch (err) { + t.fail(err) + } + + try { + new Client(new URL('https://localhost')) // eslint-disable-line + t.pass('Should not throw') + } catch (err) { + t.fail(err) + } +}) + +test('CONNECT throws in next tick', (t) => { + t.plan(3) + + const server = createServer() + server.on('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .on('end', () => { + let ticked = false + client.request({ + path: '/', + method: 'CONNECT' + }, (err) => { + t.ok(err) + t.strictSame(ticked, true) + }) + ticked = true + }) + .resume() + }) + }) +}) + +test('invalid signal', (t) => { + t.plan(8) + + const client = new Client('http://localhost:3333') + t.teardown(client.destroy.bind(client)) + + let ticked = false + client.request({ path: '/', method: 'GET', signal: {}, opaque: 'asd' }, (err, { opaque }) => { + t.equal(ticked, true) + t.equal(opaque, 'asd') + t.type(err, errors.InvalidArgumentError) + }) + client.pipeline({ path: '/', method: 'GET', signal: {} }, () => {}) + .on('error', (err) => { + t.equal(ticked, true) + t.type(err, errors.InvalidArgumentError) + }) + client.stream({ path: '/', method: 'GET', signal: {}, opaque: 'asd' }, () => {}, (err, { opaque }) => { + t.equal(ticked, true) + t.equal(opaque, 'asd') + t.type(err, errors.InvalidArgumentError) + }) + ticked = true +}) + +test('invalid body chunk does not crash', (t) => { + t.plan(1) + + const server = createServer() + server.on('request', (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + body: new Readable({ + objectMode: true, + read () { + this.push({}) + } + }), + method: 'GET' + }, (err) => { + t.equal(err.code, 'ERR_INVALID_ARG_TYPE') + }) + }) +}) + +test('socket errors', t => { + t.plan(2) + const client = new Client('http://localhost:5554') + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err) + // TODO: Why UND_ERR_SOCKET? + t.ok(err.code === 'ECONNREFUSED' || err.code === 'UND_ERR_SOCKET', err.code) + t.end() + }) +}) + +test('headers overflow', t => { + t.plan(2) + const server = createServer() + server.on('request', (req, res) => { + res.writeHead(200, { + 'x-test-1': '1', + 'x-test-2': '2' + }) + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + maxHeaderSize: 10 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err) + t.equal(err.code, 'UND_ERR_HEADERS_OVERFLOW') + t.end() + }) + }) +}) + +test('SocketError should expose socket details (net)', (t) => { + t.plan(8) + + const server = createServer() + + server.once('request', (req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err instanceof errors.SocketError) + if (err.socket.remoteFamily === 'IPv4') { + t.equal(err.socket.remoteFamily, 'IPv4') + t.equal(err.socket.localAddress, '127.0.0.1') + t.equal(err.socket.remoteAddress, '127.0.0.1') + } else { + t.equal(err.socket.remoteFamily, 'IPv6') + t.equal(err.socket.localAddress, '::1') + t.equal(err.socket.remoteAddress, '::1') + } + t.type(err.socket.localPort, 'number') + t.type(err.socket.remotePort, 'number') + t.type(err.socket.bytesWritten, 'number') + t.type(err.socket.bytesRead, 'number') + }) + }) +}) + +test('SocketError should expose socket details (tls)', (t) => { + t.plan(8) + + const server = https.createServer(pem) + + server.once('request', (req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`https://localhost:${server.address().port}`, { + tls: { + rejectUnauthorized: false + } + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err instanceof errors.SocketError) + if (err.socket.remoteFamily === 'IPv4') { + t.equal(err.socket.remoteFamily, 'IPv4') + t.equal(err.socket.localAddress, '127.0.0.1') + t.equal(err.socket.remoteAddress, '127.0.0.1') + } else { + t.equal(err.socket.remoteFamily, 'IPv6') + t.equal(err.socket.localAddress, '::1') + t.equal(err.socket.remoteAddress, '::1') + } + t.type(err.socket.localPort, 'number') + t.type(err.socket.remotePort, 'number') + t.type(err.socket.bytesWritten, 'number') + t.type(err.socket.bytesRead, 'number') + }) + }) +}) diff --git a/test/client-head-reset-override.js b/test/client-head-reset-override.js new file mode 100644 index 0000000..a7d79e2 --- /dev/null +++ b/test/client-head-reset-override.js @@ -0,0 +1,62 @@ +'use strict' + +const { createServer } = require('http') +const { test } = require('tap') +const { Client } = require('..') + +test('override HEAD reset', (t) => { + const expected = 'testing123' + const server = createServer((req, res) => { + if (req.method === 'GET') { + res.write(expected) + } + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let done + client.on('disconnect', () => { + if (!done) { + t.fail() + } + }) + + client.request({ + path: '/', + method: 'HEAD', + reset: false + }, (err, res) => { + t.error(err) + res.body.resume() + }) + + client.request({ + path: '/', + method: 'HEAD', + reset: false + }, (err, res) => { + t.error(err) + res.body.resume() + }) + + client.request({ + path: '/', + method: 'GET', + reset: false + }, (err, res) => { + t.error(err) + let str = '' + res.body.on('data', (data) => { + str += data + }).on('end', () => { + t.same(str, expected) + done = true + t.end() + }) + }) + }) +}) diff --git a/test/client-idempotent-body.js b/test/client-idempotent-body.js new file mode 100644 index 0000000..99e5371 --- /dev/null +++ b/test/client-idempotent-body.js @@ -0,0 +1,43 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') + +test('idempotent retry', (t) => { + t.plan(11) + + const body = 'world' + const server = createServer((req, res) => { + let buf = '' + req.on('data', data => { + buf += data + }).on('end', () => { + t.strictSame(buf, body) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + const _err = new Error() + + for (let n = 0; n < 4; ++n) { + client.stream({ + path: '/', + method: 'PUT', + idempotent: true, + body + }, () => { + throw _err + }, (err) => { + t.equal(err, _err) + }) + } + }) +}) diff --git a/test/client-keep-alive.js b/test/client-keep-alive.js new file mode 100644 index 0000000..393807b --- /dev/null +++ b/test/client-keep-alive.js @@ -0,0 +1,359 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const timers = require('../lib/timers') +const { kConnect } = require('../lib/core/symbols') +const { createServer } = require('net') +const http = require('http') +const FakeTimers = require('@sinonjs/fake-timers') + +test('keep-alive header', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=2s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 4e3) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('keep-alive header 0', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeoutThreshold: 500 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + client.on('disconnect', () => { + t.pass() + }) + clock.tick(600) + }).resume() + }) + }) +}) + +test('keep-alive header 1', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 0) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('keep-alive header no postfix', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=2\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 4e3) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('keep-alive not timeout', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeoutasdasd=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 1e3 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('keep-alive threshold', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=30s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 30e3, + keepAliveTimeoutThreshold: 29e3 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('keep-alive max keepalive', (t) => { + t.plan(2) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=30s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 30e3, + keepAliveMaxTimeout: 1e3 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.on('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) +}) + +test('connection close', (t) => { + t.plan(4) + + let close = false + const server = createServer((socket) => { + if (close) { + return + } + close = true + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Connection: close\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.once('disconnect', () => { + close = false + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.once('disconnect', () => { + t.pass() + clearTimeout(timeout) + }) + }).resume() + }) + }) + }) +}) + +test('Disable keep alive', (t) => { + t.plan(7) + + const ports = [] + const server = http.createServer((req, res) => { + t.notOk(ports.includes(req.socket.remotePort)) + ports.push(req.socket.remotePort) + t.match(req.headers, { connection: 'close' }) + res.writeHead(200, { connection: 'close' }) + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { pipelining: 0 }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.on('end', () => { + t.pass() + }).resume() + }) + }).resume() + }) + }) +}) diff --git a/test/client-node-max-header-size.js b/test/client-node-max-header-size.js new file mode 100644 index 0000000..b537490 --- /dev/null +++ b/test/client-node-max-header-size.js @@ -0,0 +1,23 @@ +'use strict' + +const { execSync } = require('node:child_process') +const { test } = require('tap') + +const command = 'node -e "require(\'.\').request(\'https://httpbin.org/get\')"' + +test("respect Node.js' --max-http-header-size", async (t) => { + t.throws( + // TODO: Drop the `--unhandled-rejections=throw` once we drop Node.js 14 + () => execSync(`${command} --max-http-header-size=1 --unhandled-rejections=throw`), + /UND_ERR_HEADERS_OVERFLOW/, + 'max-http-header-size=1 should throw' + ) + + t.doesNotThrow( + () => execSync(command), + /UND_ERR_HEADERS_OVERFLOW/, + 'default max-http-header-size should not throw' + ) + + t.end() +}) diff --git a/test/client-pipeline.js b/test/client-pipeline.js new file mode 100644 index 0000000..9b677a0 --- /dev/null +++ b/test/client-pipeline.js @@ -0,0 +1,1042 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const EE = require('events') +const { createServer } = require('http') +const { + pipeline, + Readable, + Transform, + Writable, + PassThrough +} = require('stream') +const { nodeMajor } = require('../lib/core/util') + +test('pipeline get', (t) => { + t.plan(17) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + { + const bufs = [] + const signal = new EE() + client.pipeline({ signal, path: '/', method: 'GET' }, ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + t.equal(signal.listenerCount('abort'), 1) + return body + }) + .end() + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + .on('close', () => { + t.equal(signal.listenerCount('abort'), 0) + }) + t.equal(signal.listenerCount('abort'), 1) + } + + { + const bufs = [] + client.pipeline({ path: '/', method: 'GET' }, ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + }) + .end() + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } + }) +}) + +test('pipeline echo', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let res = '' + const buf1 = Buffer.alloc(1e3).toString() + const buf2 = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf1) + this.push(buf2) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, encoding, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + t.equal(res, buf1 + buf2) + callback() + } + }), + (err) => { + t.error(err) + } + ) + }) +}) + +test('pipeline ignore request body', (t) => { + t.plan(2) + + let done + const server = createServer((req, res) => { + res.write('asd') + res.end() + done() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let res = '' + const buf1 = Buffer.alloc(1e3).toString() + const buf2 = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf1) + this.push(buf2) + done = () => this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, encoding, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + t.equal(res, 'asd') + callback() + } + }), + (err) => { + t.error(err) + } + ) + }) +}) + +test('pipeline invalid handler', (t) => { + t.plan(1) + + const client = new Client('http://localhost:5000') + client.pipeline({}, null).on('error', (err) => { + t.ok(/handler/.test(err)) + }) +}) + +test('pipeline invalid handler return after destroy should not error', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + const dup = client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + body.on('error', (err) => { + t.equal(err.message, 'asd') + }) + dup.destroy(new Error('asd')) + return {} + }) + .on('error', (err) => { + t.equal(err.message, 'asd') + }) + .on('close', () => { + t.pass() + }) + .end() + }) +}) + +test('pipeline error body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + process.nextTick(() => { + pt.destroy(new Error('asd')) + }) + body.on('error', (err) => { + t.ok(err) + }) + return pipeline(body, pt, () => {}) + }), + new PassThrough(), + (err) => { + t.ok(err) + } + ) + }) +}) + +test('pipeline destroy body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + process.nextTick(() => { + pt.destroy() + }) + body.on('error', (err) => { + t.ok(err) + }) + return pipeline(body, pt, () => {}) + }), + new PassThrough(), + (err) => { + t.ok(err) + } + ) + }) +}) + +test('pipeline backpressure', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const buf = Buffer.alloc(1e6).toString() + const duplex = client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + return pipeline(body, pt, () => {}) + }) + + duplex.end(buf) + duplex.on('data', () => { + duplex.pause() + setImmediate(() => { + duplex.resume() + }) + }).on('end', () => { + t.pass() + }) + }) +}) + +test('pipeline invalid handler return', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + }) + .on('error', (err) => { + t.type(err, errors.InvalidReturnValueError) + }) + .end() + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + return {} + }) + .on('error', (err) => { + t.type(err, errors.InvalidReturnValueError) + }) + .end() + }) +}) + +test('pipeline throw handler', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + throw new Error('asd') + }) + .on('error', (err) => { + t.equal(err.message, 'asd') + }) + .end() + }) +}) + +test('pipeline destroy and throw handler', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const dup = client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + dup.destroy() + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + throw new Error('asd') + }) + .end() + .on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + .on('close', () => { + t.pass() + }) + }) +}) + +test('pipeline abort res', (t) => { + t.plan(2) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + setImmediate(() => { + body.destroy() + _res.write('asdasdadasd') + const timeout = setTimeout(() => { + t.fail() + }, 100) + client.on('disconnect', () => { + clearTimeout(timeout) + t.pass() + }) + }) + return body + }) + .on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + .end() + }) +}) + +test('pipeline abort server res', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, () => { + t.fail() + }) + .on('error', (err) => { + t.type(err, errors.SocketError) + }) + .end() + }) +}) + +test('pipeline abort duplex', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT' + }, (err, data) => { + t.error(err) + data.body.resume() + + client.pipeline({ + path: '/', + method: 'PUT' + }, () => { + t.fail() + }).destroy().on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + }) + }) +}) + +test('pipeline abort piped res', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + setImmediate(() => { + pt.destroy() + }) + return pipeline(body, pt, () => {}) + }) + .on('error', (err) => { + // Node < 13 doesn't always detect premature close. + if (nodeMajor < 13) { + t.ok(err) + } else { + t.equal(err.code, 'UND_ERR_ABORTED') + } + }) + .end() + }) +}) + +test('pipeline abort piped res 2', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + body.on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + setImmediate(() => { + pt.destroy() + }) + body.pipe(pt) + return pt + }) + .on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + .end() + }) +}) + +test('pipeline abort piped res 3', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + body.on('error', (err) => { + t.equal(err.message, 'asd') + }) + setImmediate(() => { + pt.destroy(new Error('asd')) + }) + body.pipe(pt) + return pt + }) + .on('error', (err) => { + t.equal(err.message, 'asd') + }) + .end() + }) +}) + +test('pipeline abort server res after headers', (t) => { + t.plan(1) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, (data) => { + _res.destroy() + return data.body + }) + .on('error', (err) => { + t.type(err, errors.SocketError) + }) + .end() + }) +}) + +test('pipeline w/ write abort server res after headers', (t) => { + t.plan(1) + + let _res + const server = createServer((req, res) => { + req.pipe(res) + _res = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'PUT' + }, (data) => { + _res.destroy() + return data.body + }) + .on('error', (err) => { + t.type(err, errors.SocketError) + }) + .resume() + .write('asd') + }) +}) + +test('destroy in push', (t) => { + t.plan(3) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.pipeline({ path: '/', method: 'GET' }, ({ body }) => { + body.once('data', () => { + _res.write('asd') + body.on('data', (buf) => { + body.destroy() + _res.end() + }).on('error', (err) => { + t.ok(err) + }) + }) + return body + }).on('error', (err) => { + t.ok(err) + }).resume().end() + + client.pipeline({ path: '/', method: 'GET' }, ({ body }) => { + let buf = '' + body.on('data', (chunk) => { + buf = chunk.toString() + _res.end() + }).on('end', () => { + t.equal('asd', buf) + }) + return body + }).resume().end() + }) +}) + +test('pipeline args validation', (t) => { + t.plan(2) + + const client = new Client('http://localhost:5000') + + const ret = client.pipeline(null, () => {}) + ret.on('error', (err) => { + t.ok(/opts/.test(err.message)) + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('pipeline factory throw not unhandled', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, (data) => { + throw new Error('asd') + }) + .on('error', (err) => { + t.ok(err) + }) + .end() + }) +}) + +test('pipeline destroy before dispatch', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client + .pipeline({ path: '/', method: 'GET' }, ({ body }) => { + return body + }) + .on('error', (err) => { + t.ok(err) + }) + .end() + .destroy() + }) +}) + +test('pipeline legacy stream', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.write(Buffer.alloc(16e3)) + setImmediate(() => { + res.end(Buffer.alloc(16e3)) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client + .pipeline({ path: '/', method: 'GET' }, ({ body }) => { + const pt = new PassThrough() + pt.pause = null + return body.pipe(pt) + }) + .resume() + .on('end', () => { + t.pass() + }) + .end() + }) +}) + +test('pipeline objectMode', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end(JSON.stringify({ asd: 1 })) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client + .pipeline({ path: '/', method: 'GET', objectMode: true }, ({ body }) => { + return pipeline(body, new Transform({ + readableObjectMode: true, + transform (chunk, encoding, callback) { + callback(null, JSON.parse(chunk)) + } + }), () => {}) + }) + .on('data', data => { + t.strictSame(data, { asd: 1 }) + }) + .end() + }) +}) + +test('pipeline invalid opts', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end(JSON.stringify({ asd: 1 })) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.close((err) => { + t.error(err) + }) + client + .pipeline({ path: '/', method: 'GET', objectMode: true }, ({ body }) => { + t.fail() + }) + .on('error', (err) => { + t.ok(err) + }) + }) +}) + +test('pipeline CONNECT throw', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'CONNECT' + }, () => { + t.fail() + }).on('error', (err) => { + t.type(err, errors.InvalidArgumentError) + }) + client.on('disconnect', () => { + t.fail() + }) + }) +}) + +test('pipeline body without destroy', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough({ autoDestroy: false }) + pt.destroy = null + return body.pipe(pt) + }) + .end() + .on('end', () => { + t.pass() + }) + .resume() + }) +}) + +test('pipeline ignore 1xx', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => body) + .on('data', (chunk) => { + buf += chunk + }) + .on('end', () => { + t.equal(buf, 'hello') + }) + .end() + }) +}) +test('pipeline ignore 1xx and use onInfo', (t) => { + t.plan(3) + + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + } + }, ({ body }) => body) + .on('data', (chunk) => { + buf += chunk + }) + .on('end', () => { + t.equal(buf, 'hello') + t.equal(infos.length, 1) + t.equal(infos[0].statusCode, 102) + }) + .end() + }) +}) + +test('pipeline backpressure', (t) => { + t.plan(1) + + const expected = Buffer.alloc(1e6).toString() + + const server = createServer((req, res) => { + res.writeProcessing() + res.end(expected) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => body) + .end() + .pipe(new Transform({ + highWaterMark: 1, + transform (chunk, encoding, callback) { + setImmediate(() => { + callback(null, chunk) + }) + } + })) + .on('data', chunk => { + buf += chunk + }) + .on('end', () => { + t.equal(buf, expected) + }) + }) +}) + +test('pipeline abort after headers', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.writeProcessing() + res.write('asd') + setImmediate(() => { + res.write('asd') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const signal = new EE() + client.pipeline({ + path: '/', + method: 'GET', + signal + }, ({ body }) => { + process.nextTick(() => { + signal.emit('abort') + }) + return body + }) + .end() + .on('error', (err) => { + t.type(err, errors.RequestAbortedError) + }) + }) +}) diff --git a/test/client-pipelining.js b/test/client-pipelining.js new file mode 100644 index 0000000..8cd21fe --- /dev/null +++ b/test/client-pipelining.js @@ -0,0 +1,752 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { finished, Readable } = require('stream') +const { kConnect } = require('../lib/core/symbols') +const EE = require('events') +const { kBusy, kRunning, kSize } = require('../lib/core/symbols') +const { maybeWrapStream, consts } = require('./utils/async-iterators') + +test('20 times GET with pipelining 10', (t) => { + const num = 20 + t.plan(3 * num + 1) + + let count = 0 + let countGreaterThanOne = false + const server = createServer((req, res) => { + count++ + setTimeout(function () { + countGreaterThanOne = countGreaterThanOne || count > 1 + res.end(req.url) + }, 10) + }) + t.teardown(server.close.bind(server)) + + // needed to check for a warning on the maxListeners on the socket + function onWarning (warning) { + if (!/ExperimentalWarning/.test(warning)) { + t.fail() + } + } + process.on('warning', onWarning) + t.teardown(() => { + process.removeListener('warning', onWarning) + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + makeRequestAndExpectUrl(client, i, t, () => { + count-- + + if (i === num - 1) { + t.ok(countGreaterThanOne, 'seen more than one parallel request') + } + }) + } + }) +}) + +function makeRequestAndExpectUrl (client, i, t, cb) { + return client.request({ path: '/' + i, method: 'GET' }, (err, { statusCode, headers, body }) => { + cb() + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('/' + i, Buffer.concat(bufs).toString('utf8')) + }) + }) +} + +test('A client should enqueue as much as twice its pipelining factor', (t) => { + const num = 10 + let sent = 0 + // x * 6 + 1 t.ok + 5 drain + t.plan(num * 6 + 1 + 5 + 2) + + let count = 0 + let countGreaterThanOne = false + const server = createServer((req, res) => { + count++ + t.ok(count <= 5) + setTimeout(function () { + countGreaterThanOne = countGreaterThanOne || count > 1 + res.end(req.url) + }, 10) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + for (; sent < 2;) { + t.notOk(client[kSize] > client.pipelining, 'client is not full') + makeRequest() + t.ok(client[kSize] <= client.pipelining, 'we can send more requests') + } + + t.ok(client[kBusy], 'client is busy') + t.notOk(client[kSize] > client.pipelining, 'client is full') + makeRequest() + t.ok(client[kBusy], 'we must stop now') + t.ok(client[kBusy], 'client is busy') + t.ok(client[kSize] > client.pipelining, 'client is full') + + function makeRequest () { + makeRequestAndExpectUrl(client, sent++, t, () => { + count-- + setImmediate(() => { + if (client[kSize] === 0) { + t.ok(countGreaterThanOne, 'seen more than one parallel request') + const start = sent + for (; sent < start + 2 && sent < num;) { + t.notOk(client[kSize] > client.pipelining, 'client is not full') + t.ok(makeRequest()) + } + } + }) + }) + return client[kSize] <= client.pipelining + } + }) +}) + +test('pipeline 1 is 1 active request', (t) => { + t.plan(9) + + let res2 + const server = createServer((req, res) => { + res.write('asd') + res2 = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.equal(client[kSize], 1) + t.error(err) + t.notOk(client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + finished(data.body, (err) => { + t.ok(err) + client.close((err) => { + t.error(err) + }) + }) + data.body.destroy() + res2.end() + })) + data.body.resume() + res2.end() + }) + t.ok(client[kSize] <= client.pipelining) + t.ok(client[kBusy]) + t.equal(client[kSize], 1) + }) +}) + +test('pipelined chunked POST stream', (t) => { + t.plan(4 + 8 + 8) + + let a = 0 + let b = 0 + + const server = createServer((req, res) => { + req.on('data', chunk => { + // Make sure a and b don't interleave. + t.ok(a === 9 || b === 0) + res.write(chunk) + }).on('end', () => { + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: new Readable({ + read () { + this.push(++a > 8 ? null : 'a') + } + }) + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: new Readable({ + read () { + this.push(++b > 8 ? null : 'b') + } + }) + }, (err, { body }) => { + body.resume() + t.error(err) + }) + }) +}) + +test('pipelined chunked POST iterator', (t) => { + t.plan(4 + 8 + 8) + + let a = 0 + let b = 0 + + const server = createServer((req, res) => { + req.on('data', chunk => { + // Make sure a and b don't interleave. + t.ok(a === 9 || b === 0) + res.write(chunk) + }).on('end', () => { + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: (async function * () { + while (++a <= 8) { + yield 'a' + } + })() + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.error(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: (async function * () { + while (++b <= 8) { + yield 'b' + } + })() + }, (err, { body }) => { + body.resume() + t.error(err) + }) + }) +}) + +function errordInflightPost (bodyType) { + test(`errored POST body lets inflight complete ${bodyType}`, (t) => { + t.plan(6) + + let serverRes + const server = createServer() + server.on('request', (req, res) => { + serverRes = res + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .resume() + .once('data', () => { + client.request({ + path: '/', + method: 'POST', + opaque: 'asd', + body: maybeWrapStream(new Readable({ + read () { + this.destroy(new Error('kaboom')) + } + }).once('error', (err) => { + t.ok(err) + }).on('error', () => { + // Readable emits error twice... + }), bodyType) + }, (err, data) => { + t.ok(err) + t.equal(data.opaque, 'asd') + }) + client.close((err) => { + t.error(err) + }) + serverRes.end() + }) + .on('end', () => { + t.pass() + }) + }) + }) + }) +} + +errordInflightPost(consts.STREAM) +errordInflightPost(consts.ASYNC_ITERATOR) + +test('pipelining non-idempotent', (t) => { + t.plan(4) + + const server = createServer() + server.on('request', (req, res) => { + setTimeout(() => { + res.end('asd') + }, 10) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + let ended = false + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + ended = true + }) + }) + + client.request({ + path: '/', + method: 'GET', + idempotent: false + }, (err, data) => { + t.error(err) + t.equal(ended, true) + data.body.resume() + }) + }) +}) + +function pipeliningNonIdempotentWithBody (bodyType) { + test(`pipelining non-idempotent w body ${bodyType}`, (t) => { + t.plan(4) + + const server = createServer() + server.on('request', (req, res) => { + setImmediate(() => { + res.end('asd') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + let ended = false + let reading = false + client.request({ + path: '/', + method: 'POST', + body: maybeWrapStream(new Readable({ + read () { + if (reading) { + return + } + reading = true + this.push('asd') + setImmediate(() => { + this.push(null) + ended = true + }) + } + }), bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + + client.request({ + path: '/', + method: 'GET', + idempotent: false + }, (err, data) => { + t.error(err) + t.equal(ended, true) + data.body.resume() + }) + }) + }) +} + +pipeliningNonIdempotentWithBody(consts.STREAM) +pipeliningNonIdempotentWithBody(consts.ASYNC_ITERATOR) + +function pipeliningHeadBusy (bodyType) { + test(`pipelining HEAD busy ${bodyType}`, (t) => { + t.plan(7) + + const server = createServer() + server.on('request', (req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + + client[kConnect](() => { + let ended = false + client.once('disconnect', () => { + t.equal(ended, true) + }) + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + body.push(null) + t.equal(client[kBusy], true) + } + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'HEAD', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + ended = true + t.pass() + }) + }) + body.push(null) + t.equal(client[kBusy], true) + } + }) + }) + }) +} + +pipeliningHeadBusy(consts.STREAM) +pipeliningHeadBusy(consts.ASYNC_ITERATOR) + +test('pipelining empty pipeline before reset', (t) => { + t.plan(8) + + let c = 0 + const server = createServer() + server.on('request', (req, res) => { + if (c++ === 0) { + res.end('asd') + } else { + setTimeout(() => { + res.end('asd') + }, 100) + } + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + + client[kConnect](() => { + let ended = false + client.once('disconnect', () => { + t.equal(ended, true) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + t.equal(client[kBusy], false) + + client.request({ + path: '/', + method: 'HEAD', + body: 'asd' + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + ended = true + t.pass() + }) + }) + t.equal(client[kBusy], true) + t.equal(client[kRunning], 2) + }) + }) +}) + +function pipeliningIdempotentBusy (bodyType) { + test(`pipelining idempotent busy ${bodyType}`, (t) => { + t.plan(12) + + const server = createServer() + server.on('request', (req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + body.push(null) + t.equal(client[kBusy], true) + } + + client[kConnect](() => { + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + body.push(null) + t.equal(client[kBusy], true) + } + + { + const signal = new EE() + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType), + signal + }, (err, data) => { + t.ok(err) + }) + t.equal(client[kBusy], true) + signal.emit('abort') + t.equal(client[kBusy], true) + } + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + idempotent: false, + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + body.push(null) + t.equal(client[kBusy], true) + } + }) + }) + }) +} + +pipeliningIdempotentBusy(consts.STREAM) +pipeliningIdempotentBusy(consts.ASYNC_ITERATOR) + +test('pipelining blocked', (t) => { + t.plan(6) + + const server = createServer() + + let blocking = true + let count = 0 + + server.on('request', (req, res) => { + t.ok(!count || !blocking) + count++ + setImmediate(() => { + res.end('asd') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + client.request({ + path: '/', + method: 'GET', + blocking: true + }, (err, data) => { + t.error(err) + blocking = false + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) diff --git a/test/client-post.js b/test/client-post.js new file mode 100644 index 0000000..363b43c --- /dev/null +++ b/test/client-post.js @@ -0,0 +1,73 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Blob } = require('buffer') + +test('request post blob', { skip: !Blob }, (t) => { + t.plan(4) + + const server = createServer(async (req, res) => { + t.equal(req.headers['content-type'], 'application/json') + let str = '' + for await (const chunk of req) { + str += chunk + } + t.equal(str, 'asd') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + body: new Blob(['asd'], { + type: 'application/json' + }) + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + t.pass() + }) + }) + }) +}) + +test('request post arrayBuffer', { skip: !Blob }, (t) => { + t.plan(3) + + const server = createServer(async (req, res) => { + let str = '' + for await (const chunk of req) { + str += chunk + } + t.equal(str, 'asd') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const buf = Buffer.from('asd') + const dst = new ArrayBuffer(buf.byteLength) + buf.copy(new Uint8Array(dst)) + + client.request({ + path: '/', + method: 'GET', + body: dst + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + t.pass() + }) + }) + }) +}) diff --git a/test/client-reconnect.js b/test/client-reconnect.js new file mode 100644 index 0000000..ae1a206 --- /dev/null +++ b/test/client-reconnect.js @@ -0,0 +1,54 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const FakeTimers = require('@sinonjs/fake-timers') +const timers = require('../lib/timers') + +test('multiple reconnect', (t) => { + t.plan(5) + + let n = 0 + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + n === 0 ? res.destroy() : res.end('ok') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err) + t.equal(err.code, 'UND_ERR_SOCKET') + }) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + + client.on('disconnect', () => { + if (++n === 1) { + t.pass() + } + process.nextTick(() => { + clock.tick(1000) + }) + }) + }) +}) diff --git a/test/client-request.js b/test/client-request.js new file mode 100644 index 0000000..3e66705 --- /dev/null +++ b/test/client-request.js @@ -0,0 +1,997 @@ +/* globals AbortController */ + +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const EE = require('events') +const { kConnect } = require('../lib/core/symbols') +const { Readable } = require('stream') +const net = require('net') +const { promisify } = require('util') +const { NotSupportedError } = require('../lib/core/errors') +const { nodeMajor } = require('../lib/core/util') +const { parseFormDataString } = require('./utils/formdata') + +test('request dump', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + let dumped = false + client.on('disconnect', () => { + t.equal(dumped, true) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.dump().then(() => { + dumped = true + t.pass() + }) + }) + }) +}) + +test('request dump with abort signal', (t) => { + t.plan(2) + const server = createServer((req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + let ac + if (!global.AbortController) { + const { AbortController } = require('abort-controller') + ac = new AbortController() + } else { + ac = new AbortController() + } + body.dump({ signal: ac.signal }).catch((err) => { + t.equal(err.name, 'AbortError') + server.close() + }) + ac.abort() + }) + }) +}) + +test('request hwm', (t) => { + t.plan(2) + const server = createServer((req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + highWaterMark: 1000 + }, (err, { body }) => { + t.error(err) + t.same(body.readableHighWaterMark, 1000) + body.dump() + }) + }) +}) + +test('request abort before headers', (t) => { + t.plan(6) + + const signal = new EE() + const server = createServer((req, res) => { + res.end('hello') + signal.emit('abort') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET', + signal + }, (err) => { + t.type(err, errors.RequestAbortedError) + t.equal(signal.listenerCount('abort'), 0) + }) + t.equal(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + method: 'GET', + signal + }, (err) => { + t.type(err, errors.RequestAbortedError) + t.equal(signal.listenerCount('abort'), 0) + }) + t.equal(signal.listenerCount('abort'), 2) + }) + }) +}) + +test('request body destroyed on invalid callback', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const body = new Readable({ + read () {} + }) + try { + client.request({ + path: '/', + method: 'GET', + body + }, null) + } catch (err) { + t.equal(body.destroyed, true) + } + }) +}) + +test('trailers', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.writeHead(200, { Trailer: 'Content-MD5' }) + res.addTrailers({ 'Content-MD5': 'test' }) + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { body, trailers } = await client.request({ + path: '/', + method: 'GET' + }) + + body + .on('data', () => t.fail()) + .on('end', () => { + t.strictSame(trailers, { 'content-md5': 'test' }) + }) + }) +}) + +test('destroy socket abruptly', { skip: true }, async (t) => { + t.plan(2) + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + + // Unfortunately calling destroy synchronously might get us flaky results, + // therefore we delay it to the next event loop run. + setImmediate(socket.destroy.bind(socket)) + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { statusCode, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.equal(statusCode, 200) + + body.setEncoding('utf8') + + let actual = '' + + for await (const chunk of body) { + actual += chunk + } + + t.equal(actual, 'the body') +}) + +test('destroy socket abruptly with keep-alive', { skip: true }, async (t) => { + t.plan(2) + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: keep-alive', + 'Content-Length: 42', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + + // Unfortunately calling destroy synchronously might get us flaky results, + // therefore we delay it to the next event loop run. + setImmediate(socket.destroy.bind(socket)) + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { statusCode, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.equal(statusCode, 200) + + body.setEncoding('utf8') + + try { + /* eslint-disable */ + for await (const _ of body) { + // empty on purpose + } + /* eslint-enable */ + t.fail('no error') + } catch (err) { + t.pass('error happened') + } +}) + +test('request json', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('request long multibyte json', (t) => { + t.plan(1) + + const obj = { asd: 'あ'.repeat(100000) } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('request text', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('empty host header', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end(req.headers.host) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const serverAddress = `localhost:${server.address().port}` + const client = new Client(`http://${serverAddress}`) + t.teardown(client.destroy.bind(client)) + + const getWithHost = async (host, wanted) => { + const { body } = await client.request({ + path: '/', + method: 'GET', + headers: { host } + }) + t.strictSame(await body.text(), wanted) + } + + await getWithHost('test', 'test') + await getWithHost(undefined, serverAddress) + await getWithHost('', '') + }) +}) + +test('request long multibyte text', (t) => { + t.plan(1) + + const obj = { asd: 'あ'.repeat(100000) } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('request blob', { skip: nodeMajor < 16 }, (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + const blob = await body.blob() + t.strictSame(obj, JSON.parse(await blob.text())) + t.equal(blob.type, 'application/json') + }) +}) + +test('request arrayBuffer', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const ab = await body.arrayBuffer() + + t.strictSame(Buffer.from(JSON.stringify(obj)), Buffer.from(ab)) + t.ok(ab instanceof ArrayBuffer) + }) +}) + +test('request body', { skip: nodeMajor < 16 }, (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + let x = '' + for await (const chunk of body.body) { + x += Buffer.from(chunk) + } + t.strictSame(JSON.stringify(obj), x) + }) +}) + +test('request post body no missing data', { skip: nodeMajor < 16 }, (t) => { + t.plan(2) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'asd') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET', + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }), + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body no extra data handler', { skip: nodeMajor < 16 }, (t) => { + t.plan(3) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'asd') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const reqBody = new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + process.nextTick(() => { + t.equal(reqBody.listenerCount('data'), 0) + }) + const { body } = await client.request({ + path: '/', + method: 'GET', + body: reqBody, + maxRedirections: 0 + }) + await body.text() + t.pass() + }) +}) + +test('request with onInfo callback', (t) => { + t.plan(3) + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ foo: 'bar' })) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { infos.push(x) } + }) + t.equal(infos.length, 1) + t.equal(infos[0].statusCode, 102) + t.pass() + }) +}) + +test('request with onInfo callback but socket is destroyed before end of response', (t) => { + t.plan(5) + const infos = [] + let response + const server = createServer((req, res) => { + response = res + res.writeProcessing() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + try { + await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + response.destroy() + } + }) + t.error() + } catch (e) { + t.ok(e) + t.equal(e.message, 'other side closed') + } + t.equal(infos.length, 1) + t.equal(infos[0].statusCode, 102) + t.pass() + }) +}) + +test('request onInfo callback headers parsing', async (t) => { + t.plan(4) + const infos = [] + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 103 Early Hints', + 'Link: ; rel=preload; as=style', + '', + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { infos.push(x) } + }) + await body.dump() + t.equal(infos.length, 1) + t.equal(infos[0].statusCode, 103) + t.same(infos[0].headers, { link: '; rel=preload; as=style' }) + t.pass() +}) + +test('request raw responseHeaders', async (t) => { + t.plan(4) + const infos = [] + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 103 Early Hints', + 'Link: ; rel=preload; as=style', + '', + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { body, headers } = await client.request({ + path: '/', + method: 'GET', + responseHeaders: 'raw', + onInfo: (x) => { infos.push(x) } + }) + await body.dump() + t.equal(infos.length, 1) + t.same(infos[0].headers, ['Link', '; rel=preload; as=style']) + t.same(headers, ['Date', 'Sat, 09 Oct 2010 14:28:02 GMT', 'Connection', 'close']) + t.pass() +}) + +test('request formData', { skip: nodeMajor < 16 }, (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + try { + await body.formData() + t.fail('should throw NotSupportedError') + } catch (error) { + t.ok(error instanceof NotSupportedError) + } + }) +}) + +test('request text2', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const p = body.text() + let ret = '' + body.on('data', chunk => { + ret += chunk + }).on('end', () => { + t.equal(JSON.stringify(obj), ret) + }) + t.strictSame(JSON.stringify(obj), await p) + }) +}) + +test('request with FormData body', { skip: nodeMajor < 16 }, (t) => { + const { FormData } = require('../') + const { Blob } = require('buffer') + + const fd = new FormData() + fd.set('key', 'value') + fd.set('file', new Blob(['Hello, world!']), 'hello_world.txt') + + const server = createServer(async (req, res) => { + const contentType = req.headers['content-type'] + // ensure we received a multipart/form-data header + t.ok(/^multipart\/form-data; boundary=-+formdata-undici-0\d+$/.test(contentType)) + + const chunks = [] + + for await (const chunk of req) { + chunks.push(chunk) + } + + const { fileMap, fields } = await parseFormDataString( + Buffer.concat(chunks), + contentType + ) + + t.same(fields[0], { key: 'key', value: 'value' }) + t.ok(fileMap.has('file')) + t.equal(fileMap.get('file').data.toString(), 'Hello, world!') + t.same(fileMap.get('file').info, { + filename: 'hello_world.txt', + encoding: '7bit', + mimeType: 'application/octet-stream' + }) + + return res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await client.request({ + path: '/', + method: 'POST', + body: fd + }) + + t.end() + }) +}) + +test('request with FormData body on node < 16', { skip: nodeMajor >= 16 }, async (t) => { + t.plan(1) + + // a FormData polyfill, for example + class FormData {} + + const fd = new FormData() + + const client = new Client('http://localhost:3000') + t.teardown(client.destroy.bind(client)) + + await t.rejects(client.request({ + path: '/', + method: 'POST', + body: fd + }), errors.InvalidArgumentError) +}) + +test('request post body Buffer from string', (t) => { + t.plan(2) + const requestBody = Buffer.from('abcdefghijklmnopqrstuvwxyz') + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'abcdefghijklmnopqrstuvwxyz') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body Buffer from buffer', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = Buffer.from(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body Uint8Array', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Uint8Array(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body Uint32Array', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Uint32Array(fullBuffer.buffer, 8, 4) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body Float64Array', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Float64Array(fullBuffer.buffer, 8, 2) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body BigUint64Array', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new BigUint64Array(fullBuffer.buffer, 8, 2) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) + +test('request post body DataView', (t) => { + t.plan(2) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new DataView(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.equal(ret, 'ijklmnopqrstuvwx') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.pass() + }) +}) diff --git a/test/client-stream.js b/test/client-stream.js new file mode 100644 index 0000000..a230c44 --- /dev/null +++ b/test/client-stream.js @@ -0,0 +1,847 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { PassThrough, Writable, Readable } = require('stream') +const EE = require('events') + +test('stream get', (t) => { + t.plan(9) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.stream({ + signal, + path: '/', + method: 'GET', + opaque: new PassThrough() + }, ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + return pt + }, (err) => { + t.equal(signal.listenerCount('abort'), 0) + t.error(err) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('stream promise get', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + await client.stream({ + path: '/', + method: 'GET', + opaque: new PassThrough() + }, ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + return pt + }) + }) +}) + +test('stream GET destroy res', (t) => { + t.plan(14) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, ({ statusCode, headers }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const pt = new PassThrough() + .on('error', (err) => { + t.ok(err) + }) + .on('data', () => { + pt.destroy(new Error('kaboom')) + }) + + return pt + }, (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, ({ statusCode, headers }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + let ret = '' + const pt = new PassThrough() + pt.on('data', chunk => { + ret += chunk + }).on('end', () => { + t.equal(ret, 'hello') + }) + + return pt + }, (err) => { + t.error(err) + }) + }) +}) + +test('stream GET remote destroy', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.write('asd') + setImmediate(() => { + res.destroy() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.on('error', (err) => { + t.ok(err) + }) + return pt + }, (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.on('error', (err) => { + t.ok(err) + }) + return pt + }).catch((err) => { + t.ok(err) + }) + }) +}) + +test('stream response resume back pressure and non standard error', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.write(Buffer.alloc(1e3)) + setImmediate(() => { + res.write(Buffer.alloc(1e7)) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const pt = new PassThrough() + client.stream({ + path: '/', + method: 'GET' + }, () => { + pt.on('data', () => { + pt.emit('error', new Error('kaboom')) + }).once('error', (err) => { + t.equal(err.message, 'kaboom') + }) + return pt + }, (err) => { + t.ok(err) + t.equal(pt.destroyed, true) + }) + + client.once('disconnect', (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.resume() + return pt + }, (err) => { + t.error(err) + }) + }) +}) + +test('stream waits only for writable side', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(1e3)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const pt = new PassThrough({ autoDestroy: false }) + client.stream({ + path: '/', + method: 'GET' + }, () => pt, (err) => { + t.error(err) + t.equal(pt.destroyed, false) + }) + }) +}) + +test('stream args validation', (t) => { + t.plan(3) + + const client = new Client('http://localhost:5000') + client.stream({ + path: '/', + method: 'GET' + }, null, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.stream(null, null, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + + try { + client.stream(null, null, 'asd') + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('stream args validation promise', (t) => { + t.plan(2) + + const client = new Client('http://localhost:5000') + client.stream({ + path: '/', + method: 'GET' + }, null).catch((err) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.stream(null, null).catch((err) => { + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('stream destroy if not readable', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + const pt = new PassThrough() + pt.readable = false + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + return pt + }, (err) => { + t.error(err) + t.equal(pt.destroyed, true) + }) + }) +}) + +test('stream server side destroy', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + t.fail() + }, (err) => { + t.type(err, errors.SocketError) + }) + }) +}) + +test('stream invalid return', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.write('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + return {} + }, (err) => { + t.type(err, errors.InvalidReturnValueError) + }) + }) +}) + +test('stream body without destroy', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough({ autoDestroy: false }) + pt.destroy = null + pt.resume() + return pt + }, (err) => { + t.error(err) + }) + }) +}) + +test('stream factory abort', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + signal.emit('abort') + return new PassThrough() + }, (err) => { + t.equal(signal.listenerCount('abort'), 0) + t.type(err, errors.RequestAbortedError) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('stream factory throw', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + throw new Error('asd') + }, (err) => { + t.equal(err.message, 'asd') + }) + client.stream({ + path: '/', + method: 'GET' + }, () => { + throw new Error('asd') + }, (err) => { + t.equal(err.message, 'asd') + }) + client.stream({ + path: '/', + method: 'GET' + }, () => { + return new PassThrough() + }, (err) => { + t.error(err) + }) + }) +}) + +test('stream CONNECT throw', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'CONNECT' + }, () => { + }, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + }) +}) + +test('stream abort after complete', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const pt = new PassThrough() + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + return pt + }, (err) => { + t.error(err) + signal.emit('abort') + }) + }) +}) + +test('stream abort before dispatch', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const pt = new PassThrough() + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + return pt + }, (err) => { + t.type(err, errors.RequestAbortedError) + }) + signal.emit('abort') + }) +}) + +test('trailers', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.writeHead(200, { Trailer: 'Content-MD5' }) + res.addTrailers({ 'Content-MD5': 'test' }) + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => new PassThrough(), (err, data) => { + t.error(err) + t.strictSame(data.trailers, { 'content-md5': 'test' }) + }) + }) +}) + +test('stream ignore 1xx', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.stream({ + path: '/', + method: 'GET' + }, () => new Writable({ + write (chunk, encoding, callback) { + buf += chunk + callback() + } + }), (err, data) => { + t.error(err) + t.equal(buf, 'hello') + }) + }) +}) + +test('stream ignore 1xx and use onInfo', (t) => { + t.plan(4) + + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.stream({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + } + }, () => new Writable({ + write (chunk, encoding, callback) { + buf += chunk + callback() + } + }), (err, data) => { + t.error(err) + t.equal(buf, 'hello') + t.equal(infos.length, 1) + t.equal(infos[0].statusCode, 102) + }) + }) +}) + +test('stream backpressure', (t) => { + t.plan(2) + + const expected = Buffer.alloc(1e6).toString() + + const server = createServer((req, res) => { + res.writeProcessing() + res.end(expected) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.stream({ + path: '/', + method: 'GET' + }, () => new Writable({ + highWaterMark: 1, + write (chunk, encoding, callback) { + buf += chunk + process.nextTick(callback) + } + }), (err, data) => { + t.error(err) + t.equal(buf, expected) + }) + }) +}) + +test('stream body destroyed on invalid callback', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const body = new Readable({ + read () {} + }) + try { + client.stream({ + path: '/', + method: 'GET', + body + }, () => {}, null) + } catch (err) { + t.equal(body.destroyed, true) + } + }) +}) + +test('stream needDrain', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4096)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(() => { + client.destroy() + }) + + const dst = new PassThrough() + dst.pause() + + if (dst.writableNeedDrain === undefined) { + Object.defineProperty(dst, 'writableNeedDrain', { + get () { + return this._writableState.needDrain + } + }) + } + + while (dst.write(Buffer.alloc(4096))) { + // Do nothing. + } + + const orgWrite = dst.write + dst.write = () => t.fail() + const p = client.stream({ + path: '/', + method: 'GET' + }, () => { + t.equal(dst._writableState.needDrain, true) + t.equal(dst.writableNeedDrain, true) + + setImmediate(() => { + dst.write = (...args) => { + orgWrite.call(dst, ...args) + } + dst.resume() + }) + + return dst + }) + + p.then(() => { + t.pass() + }) + }) +}) + +test('stream legacy needDrain', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4096)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(() => { + client.destroy() + }) + + const dst = new PassThrough() + dst.pause() + + if (dst.writableNeedDrain !== undefined) { + Object.defineProperty(dst, 'writableNeedDrain', { + get () { + } + }) + } + + while (dst.write(Buffer.alloc(4096))) { + // Do nothing + } + + const orgWrite = dst.write + dst.write = () => t.fail() + const p = client.stream({ + path: '/', + method: 'GET' + }, () => { + t.equal(dst._writableState.needDrain, true) + t.equal(dst.writableNeedDrain, undefined) + + setImmediate(() => { + dst.write = (...args) => { + orgWrite.call(dst, ...args) + } + dst.resume() + }) + + return dst + }) + + p.then(() => { + t.pass() + }) + }) + + test('stream throwOnError', (t) => { + t.plan(2) + + const errStatusCode = 500 + const errMessage = 'Internal Server Error' + + const server = createServer((req, res) => { + res.writeHead(errStatusCode, { 'Content-Type': 'text/plain' }) + res.end(errMessage) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.stream({ + path: '/', + method: 'GET', + throwOnError: true, + opaque: new PassThrough() + }, ({ opaque: pt }) => { + pt.on('data', () => { + t.fail() + }) + return pt + }, (e) => { + t.equal(e.status, errStatusCode) + t.equal(e.body, errMessage) + t.end() + }) + }) + }) + + test('steam throwOnError=true, error on stream', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.stream({ + path: '/', + method: 'GET', + throwOnError: true, + opaque: new PassThrough() + }, () => { + throw new Error('asd') + }, (e) => { + t.equal(e.message, 'asd') + }) + }) + }) +}) diff --git a/test/client-timeout.js b/test/client-timeout.js new file mode 100644 index 0000000..5f1686a --- /dev/null +++ b/test/client-timeout.js @@ -0,0 +1,197 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const FakeTimers = require('@sinonjs/fake-timers') +const timers = require('../lib/timers') + +test('refresh timeout on pause', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.flushHeaders() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 500 + }) + t.teardown(client.destroy.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers, resume) { + setTimeout(() => { + resume() + }, 1000) + return false + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.type(err, errors.BodyTimeoutError) + } + }) + }) +}) + +test('start headers timeout after request body', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 100 + }) + t.teardown(client.destroy.bind(client)) + + const body = new Readable({ read () {} }) + client.dispatch({ + path: '/', + body, + method: 'GET' + }, { + onConnect () { + process.nextTick(() => { + clock.tick(200) + }) + queueMicrotask(() => { + body.push(null) + body.on('end', () => { + clock.tick(200) + }) + }) + }, + onHeaders (statusCode, headers, resume) { + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.equal(body.readableEnded, true) + t.type(err, errors.HeadersTimeoutError) + } + }) + }) +}) + +test('start headers timeout after async iterator request body', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 100 + }) + t.teardown(client.destroy.bind(client)) + let res + const body = (async function * () { + await new Promise((resolve) => { res = resolve }) + process.nextTick(() => { + clock.tick(200) + }) + })() + client.dispatch({ + path: '/', + body, + method: 'GET' + }, { + onConnect () { + process.nextTick(() => { + clock.tick(200) + }) + queueMicrotask(() => { + res() + }) + }, + onHeaders (statusCode, headers, resume) { + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.type(err, errors.HeadersTimeoutError) + } + }) + }) +}) + +test('parser resume with no body timeout', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.destroy.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers, resume) { + setTimeout(resume, 2000) + return false + }, + onData () { + + }, + onComplete () { + t.pass() + }, + onError (err) { + t.error(err) + } + }) + }) +}) diff --git a/test/client-unref.js b/test/client-unref.js new file mode 100644 index 0000000..c7e3a5d --- /dev/null +++ b/test/client-unref.js @@ -0,0 +1,47 @@ +'use strict' + +const { Worker, isMainThread, workerData } = require('worker_threads') + +if (isMainThread) { + const tap = require('tap') + const { createServer } = require('http') + + tap.test('client automatically closes itself when idle', t => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + server.keepAliveTimeout = 9999 + + server.listen(0, () => { + const url = `http://localhost:${server.address().port}` + const worker = new Worker(__filename, { workerData: { url } }) + worker.on('exit', code => { + t.equal(code, 0) + }) + }) + }) + + tap.test('client automatically closes itself if the server is not there', t => { + t.plan(1) + + const url = 'http://localhost:4242' // hopefully empty port + const worker = new Worker(__filename, { workerData: { url } }) + worker.on('exit', code => { + t.equal(code, 0) + }) + }) +} else { + const { Client } = require('..') + + const client = new Client(workerData.url) + client.request({ path: '/', method: 'GET' }, () => { + // We do not care about Errors + + setTimeout(() => { + throw new Error() + }, 1e3).unref() + }) +} diff --git a/test/client-upgrade.js b/test/client-upgrade.js new file mode 100644 index 0000000..4ccbcce --- /dev/null +++ b/test/client-upgrade.js @@ -0,0 +1,452 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const net = require('net') +const http = require('http') +const EE = require('events') +const { kBusy } = require('../lib/core/symbols') + +test('basic upgrade', (t) => { + t.plan(6) + + const server = net.createServer((c) => { + c.on('data', (d) => { + t.ok(/upgrade: websocket/i.test(d)) + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + + c.on('end', () => { + c.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.upgrade({ + signal, + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.error(err) + + t.equal(signal.listenerCount('abort'), 0) + + const { headers, socket } = data + + let recvData = '' + data.socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.equal(recvData.toString(), 'Body') + }) + + t.same(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic upgrade promise', (t) => { + t.plan(2) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + + c.on('end', () => { + c.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { headers, socket } = await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.equal(recvData.toString(), 'Body') + }) + + t.same(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) +}) + +test('upgrade error', (t) => { + t.plan(1) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('\r\n') + c.write('Body') + }) + c.on('error', () => { + // Whether we get an error, end or close is undefined. + // Ignore error. + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + } catch (err) { + t.ok(err) + } + }) +}) + +test('upgrade invalid opts', (t) => { + t.plan(6) + + const client = new Client('http://localhost:5432') + + client.upgrade(null, err => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid opts') + }) + + try { + client.upgrade(null, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid opts') + } + + try { + client.upgrade({ path: '/' }, null) + t.fail() + } catch (err) { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid callback') + } +}) + +test('basic upgrade2', (t) => { + t.plan(3) + + const server = http.createServer() + server.on('upgrade', (req, c, head) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.error(err) + + const { headers, socket } = data + + let recvData = '' + data.socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.equal(recvData.toString(), 'Body') + }) + + t.same(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) + }) +}) + +test('upgrade wait for empty pipeline', (t) => { + t.plan(7) + + let canConnect = false + const server = http.createServer((req, res) => { + res.end() + canConnect = true + }) + server.on('upgrade', (req, c, firstBodyChunk) => { + t.equal(canConnect, true) + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + client.once('connect', () => { + process.nextTick(() => { + t.equal(client[kBusy], false) + + client.upgrade({ + path: '/' + }, (err, { socket }) => { + t.error(err) + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.equal(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }) + t.equal(client[kBusy], true) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + }) + }) + }) +}) + +test('upgrade aborted', (t) => { + t.plan(6) + + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('upgrade', (req, c, firstBodyChunk) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + const signal = new EE() + client.upgrade({ + path: '/', + signal, + opaque: 'asd' + }, (err, { opaque }) => { + t.equal(opaque, 'asd') + t.type(err, errors.RequestAbortedError) + t.equal(signal.listenerCount('abort'), 0) + }) + t.equal(client[kBusy], true) + t.equal(signal.listenerCount('abort'), 1) + signal.emit('abort') + + client.close(() => { + t.pass() + }) + }) +}) + +test('basic aborted after res', (t) => { + t.plan(1) + + const signal = new EE() + const server = http.createServer() + server.on('upgrade', (req, c, head) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + c.on('error', () => { + + }) + signal.emit('abort') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket', + signal + }, (err) => { + t.type(err, errors.RequestAbortedError) + }) + }) +}) + +test('basic upgrade error', (t) => { + t.plan(2) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + c.on('error', () => { + + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const _err = new Error() + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.error(err) + data.socket.on('error', (err) => { + t.equal(err, _err) + }) + throw _err + }) + }) +}) + +test('upgrade disconnect', (t) => { + t.plan(3) + + const server = net.createServer(connection => { + connection.destroy() + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.on('disconnect', (origin, [self], error) => { + t.equal(client, self) + t.type(error, Error) + }) + + client + .upgrade({ path: '/', method: 'GET' }) + .then(() => { + t.fail() + }) + .catch(error => { + t.type(error, Error) + }) + }) +}) + +test('upgrade invalid signal', (t) => { + t.plan(2) + + const server = net.createServer(() => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.on('disconnect', () => { + t.fail() + }) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket', + signal: 'error', + opaque: 'asd' + }, (err, { opaque }) => { + t.equal(opaque, 'asd') + t.type(err, errors.InvalidArgumentError) + }) + }) +}) diff --git a/test/client-write-max-listeners.js b/test/client-write-max-listeners.js new file mode 100644 index 0000000..118cdaa --- /dev/null +++ b/test/client-write-max-listeners.js @@ -0,0 +1,51 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') + +test('socket close listener does not leak', (t) => { + t.plan(32) + + const server = createServer() + + server.on('request', (req, res) => { + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const makeBody = () => { + return new Readable({ + read () { + process.nextTick(() => { + this.push(null) + }) + } + }) + } + + const onRequest = (err, data) => { + t.error(err) + data.body.on('end', () => t.pass()).resume() + } + + function onWarning (warning) { + if (!/ExperimentalWarning/.test(warning)) { + t.fail() + } + } + process.on('warning', onWarning) + t.teardown(() => { + process.removeListener('warning', onWarning) + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + for (let n = 0; n < 16; ++n) { + client.request({ path: '/', method: 'GET', body: makeBody() }, onRequest) + } + }) +}) diff --git a/test/client.js b/test/client.js new file mode 100644 index 0000000..92315d6 --- /dev/null +++ b/test/client.js @@ -0,0 +1,2096 @@ +'use strict' + +const { readFileSync, createReadStream } = require('fs') +const { createServer } = require('http') +const { Readable } = require('stream') +const { test } = require('tap') +const { Client, errors } = require('..') +const { kSocket } = require('../lib/core/symbols') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') +const EE = require('events') +const { kUrl, kSize, kConnect, kBusy, kConnected, kRunning } = require('../lib/core/symbols') + +const hasIPv6 = (() => { + const iFaces = require('os').networkInterfaces() + const re = process.platform === 'win32' ? /Loopback Pseudo-Interface/ : /lo/ + return Object.keys(iFaces).some( + (name) => re.test(name) && iFaces[name].some(({ family }) => family === 6) + ) +})() + +test('basic get', (t) => { + t.plan(24) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(undefined, req.headers.foo) + t.equal('bar', req.headers.bar) + t.equal(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + t.equal(client[kUrl].origin, `http://localhost:${server.address().port}`) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(signal.listenerCount('abort'), 1) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(signal.listenerCount('abort'), 0) + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.equal(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic get with custom request.reset=true', (t) => { + t.plan(26) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(req.headers.connection, 'close') + t.equal(undefined, req.headers.foo) + t.equal('bar', req.headers.bar) + t.equal(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, {}) + t.teardown(client.close.bind(client)) + + t.equal(client[kUrl].origin, `http://localhost:${server.address().port}`) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + reset: true, + headers: reqHeaders + }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(signal.listenerCount('abort'), 1) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(signal.listenerCount('abort'), 0) + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.equal(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + reset: true, + method: 'GET', + headers: reqHeaders + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic get with query params', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.strictSame(searchParamsObject, { + bool: 'true', + foo: '1', + bar: 'bar', + '%60~%3A%24%2C%2B%5B%5D%40%5E*()-': '%60~%3A%24%2C%2B%5B%5D%40%5E*()-', + multi: ['1', '2'], + nullVal: '', + undefinedVal: '' + }) + + res.statusCode = 200 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const query = { + bool: true, + foo: 1, + bar: 'bar', + nullVal: null, + undefinedVal: undefined, + '`~:$,+[]@^*()-': '`~:$,+[]@^*()-', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.error(err) + const { statusCode } = data + t.equal(statusCode, 200) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get with query params fails if url includes hashmark', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + foo: 1, + bar: 'bar', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/#', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) +}) + +test('basic get with empty query params', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.strictSame(searchParamsObject, {}) + + res.statusCode = 200 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const query = {} + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.error(err) + const { statusCode } = data + t.equal(statusCode, 200) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get with query params partially in path', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail() + }) + t.teardown(server.close.bind(server)) + + const query = { + foo: 1 + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/?bar=2', + method: 'GET', + query + }, (err, data) => { + t.equal(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) +}) + +test('basic get returns 400 when configured to throw on errors (callback)', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + res.statusCode = 400 + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + throwOnError: true + }, (err) => { + t.equal(err.message, 'Response status code 400: Bad Request') + t.equal(err.status, 400) + t.equal(err.statusCode, 400) + t.equal(err.headers.connection, 'keep-alive') + t.equal(err.headers['content-length'], '5') + t.same(err.body, null) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get returns 400 when configured to throw on errors and correctly handles malformed json (callback)', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.writeHead(400, 'Invalid params', { 'content-type': 'application/json' }) + res.end('Invalid params') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + throwOnError: true + }, (err) => { + t.equal(err.message, 'Response status code 400: Invalid params') + t.equal(err.status, 400) + t.equal(err.statusCode, 400) + t.equal(err.headers.connection, 'keep-alive') + t.same(err.body, null) + }) + t.equal(signal.listenerCount('abort'), 1) + }) +}) + +test('basic get returns 400 when configured to throw on errors (promise)', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.writeHead(400, 'Invalid params', { 'content-type': 'text/plain' }) + res.end('Invalid params') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + try { + await client.request({ + signal, + path: '/', + method: 'GET', + throwOnError: true + }) + t.fail('Should throw an error') + } catch (err) { + t.equal(err.message, 'Response status code 400: Invalid params') + t.equal(err.status, 400) + t.equal(err.statusCode, 400) + t.equal(err.body, 'Invalid params') + t.equal(err.headers.connection, 'keep-alive') + t.equal(err.headers['content-type'], 'text/plain') + } + }) +}) + +test('basic get returns error body when configured to throw on errors', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + const body = { msg: 'Error', details: { code: 94 } } + const bodyAsString = JSON.stringify(body) + res.writeHead(400, 'Invalid params', { + 'Content-Type': 'application/json' + }) + res.end(bodyAsString) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + const signal = new EE() + try { + await client.request({ + signal, + path: '/', + method: 'GET', + throwOnError: true + }) + t.fail('Should throw an error') + } catch (err) { + t.equal(err.message, 'Response status code 400: Invalid params') + t.equal(err.status, 400) + t.equal(err.statusCode, 400) + t.equal(err.headers.connection, 'keep-alive') + t.equal(err.headers['content-type'], 'application/json') + t.same(err.body, { msg: 'Error', details: { code: 94 } }) + } + }) +}) + +test('basic head', (t) => { + t.plan(14) + + const server = createServer((req, res) => { + t.equal('/123', req.url) + t.equal('HEAD', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.pass() + }) + }) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) + +test('basic head (IPv6)', { skip: !hasIPv6 }, (t) => { + t.plan(14) + + const server = createServer((req, res) => { + t.equal('/123', req.url) + t.equal('HEAD', req.method) + t.equal(`[::1]:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, '::', () => { + const client = new Client(`http://[::1]:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.pass() + }) + }) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) + +test('get with host header', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal('example.com', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET', headers: { host: 'example.com' } }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello from example.com', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('get with host header (IPv6)', { skip: !hasIPv6 }, (t) => { + t.plan(7) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal('[::1]', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, '::', () => { + const client = new Client(`http://[::1]:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET', headers: { host: '[::1]' } }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello from [::1]', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('head with host header', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('HEAD', req.method) + t.equal('example.com', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'HEAD', headers: { host: 'example.com' } }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) + +function postServer (t, expected) { + return function (req, res) { + t.equal(req.url, '/') + t.equal(req.method, 'POST') + t.notSame(req.headers['content-length'], null) + + req.setEncoding('utf8') + let data = '' + + req.on('data', function (d) { data += d }) + + req.on('end', () => { + t.equal(data, expected) + res.end('hello') + }) + } +} + +test('basic POST with string', (t) => { + t.plan(7) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'POST', body: expected }, (err, data) => { + t.error(err) + t.equal(data.statusCode, 200) + const bufs = [] + data.body + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with empty string', (t) => { + t.plan(7) + + const server = createServer(postServer(t, '')) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'POST', body: '' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with string and content-length', (t) => { + t.plan(7) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + body: expected + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with Buffer', (t) => { + t.plan(7) + + const expected = readFileSync(__filename) + + const server = createServer(postServer(t, expected.toString())) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'POST', body: expected }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with stream', (t) => { + t.plan(7) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: createReadStream(__filename) + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with paused stream', (t) => { + t.plan(7) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const stream = createReadStream(__filename) + stream.pause() + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: stream + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with custom stream', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + req.resume().on('end', () => { + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const body = new EE() + body.pipe = () => {} + client.request({ + path: '/', + method: 'POST', + headersTimeout: 0, + body + }, (err, data) => { + t.error(err) + t.equal(data.statusCode, 200) + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.strictSame(client[kBusy], true) + + body.on('close', () => { + body.emit('end') + }) + + client.on('connect', () => { + setImmediate(() => { + body.emit('data', '') + while (!client[kSocket]._writableState.needDrain) { + body.emit('data', Buffer.alloc(4096)) + } + client[kSocket].on('drain', () => { + body.emit('data', Buffer.alloc(4096)) + body.emit('close') + }) + }) + }) + }) +}) + +test('basic POST with iterator', (t) => { + t.plan(3) + + const expected = 'hello' + + const server = createServer((req, res) => { + req.resume().on('end', () => { + res.end(expected) + }) + }) + t.teardown(server.close.bind(server)) + + const iterable = { + [Symbol.iterator]: function * () { + for (let i = 0; i < expected.length - 1; i++) { + yield expected[i] + } + return expected[expected.length - 1] + } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + requestTimeout: 0, + body: iterable + }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with iterator with invalid data', (t) => { + t.plan(1) + + const server = createServer(() => {}) + t.teardown(server.close.bind(server)) + + const iterable = { + [Symbol.iterator]: function * () { + yield 0 + } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + requestTimeout: 0, + body: iterable + }, err => { + t.ok(err instanceof TypeError) + }) + }) +}) + +test('basic POST with async iterator', (t) => { + t.plan(7) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: wrapWithAsyncIterable(createReadStream(__filename)) + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with transfer encoding: chunked', (t) => { + t.plan(8) + + let body + const server = createServer(function (req, res) { + t.equal(req.url, '/') + t.equal(req.method, 'POST') + t.same(req.headers['content-length'], null) + t.equal(req.headers['transfer-encoding'], 'chunked') + + body.push(null) + + req.setEncoding('utf8') + let data = '' + + req.on('data', function (d) { data += d }) + + req.on('end', () => { + t.equal(data, 'asd') + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + body = new Readable({ + read () { } + }) + body.push('asd') + client.request({ + path: '/', + method: 'POST', + // no content-length header + body + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('basic POST with empty stream', (t) => { + t.plan(4) + + const server = createServer(function (req, res) { + t.same(req.headers['content-length'], 0) + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const body = new Readable({ + autoDestroy: false, + read () { + }, + destroy (err, callback) { + callback(!this._readableState.endEmitted ? new Error('asd') : err) + } + }).on('end', () => { + process.nextTick(() => { + t.equal(body.destroyed, true) + }) + }) + body.push(null) + client.request({ + path: '/', + method: 'POST', + body + }, (err, { statusCode, headers, body }) => { + t.error(err) + body + .on('data', () => { + t.fail() + }) + .on('end', () => { + t.pass() + }) + }) + }) +}) + +test('10 times GET', (t) => { + const num = 10 + t.plan(3 * 10) + + const server = createServer((req, res) => { + res.end(req.url) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + client.request({ path: '/' + i, method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('/' + i, Buffer.concat(bufs).toString('utf8')) + }) + }) + } + }) +}) + +test('10 times HEAD', (t) => { + const num = 10 + t.plan(3 * 10) + + const server = createServer((req, res) => { + res.end(req.url) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + client.request({ path: '/' + i, method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + body + .resume() + .on('end', () => { + t.pass() + }) + }) + } + }) +}) + +test('Set-Cookie', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.setHeader('Set-Cookie', ['a cookie', 'another cookie', 'more cookies']) + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.strictSame(headers['set-cookie'], ['a cookie', 'another cookie', 'more cookies']) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('ignore request header mutations', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + t.equal(req.headers.test, 'test') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const headers = { test: 'test' } + client.request({ + path: '/', + method: 'GET', + headers + }, (err, { body }) => { + t.error(err) + body.resume() + }) + headers.test = 'asd' + }) +}) + +test('url-like url', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body.resume() + }) + }) +}) + +test('an absolute url as path', (t) => { + t.plan(2) + + const path = 'http://example.com' + + const server = createServer((req, res) => { + t.equal(req.url, path) + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + t.teardown(client.close.bind(client)) + + client.request({ path, method: 'GET' }, (err, data) => { + t.error(err) + data.body.resume() + }) + }) +}) + +test('multiple destroy callback', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body + .resume() + .on('error', () => { + t.pass() + }) + client.destroy(new Error(), (err) => { + t.error(err) + }) + client.destroy(new Error(), (err) => { + t.error(err) + }) + }) + }) +}) + +test('only one streaming req at a time', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 4 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + method: 'PUT', + idempotent: true, + body: new Readable({ + read () { + setImmediate(() => { + t.equal(client[kBusy], true) + this.push(null) + }) + } + }).on('resume', () => { + t.equal(client[kSize], 1) + }) + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + t.equal(client[kBusy], true) + }) + }) +}) + +test('only one async iterating req at a time', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 4 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + const body = wrapWithAsyncIterable(new Readable({ + read () { + setImmediate(() => { + t.equal(client[kBusy], true) + this.push(null) + }) + } + })) + client.request({ + path: '/', + method: 'PUT', + idempotent: true, + body + }, (err, data) => { + t.error(err) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + t.equal(client[kBusy], true) + }) + }) +}) + +test('300 requests succeed', (t) => { + t.plan(300 * 3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + for (let n = 0; n < 300; ++n) { + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.on('data', (chunk) => { + t.equal(chunk.toString(), 'asd') + }).on('end', () => { + t.pass() + }) + }) + } + }) +}) + +test('request args validation', (t) => { + t.plan(2) + + const client = new Client('http://localhost:5000') + + client.request(null, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + + try { + client.request(null, 'asd') + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('request args validation promise', (t) => { + t.plan(1) + + const client = new Client('http://localhost:5000') + + client.request(null).catch((err) => { + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('increase pipelining', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + req.resume() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, () => { + if (!client.destroyed) { + t.fail() + } + }) + + client.request({ + path: '/', + method: 'GET' + }, () => { + if (!client.destroyed) { + t.fail() + } + }) + + t.equal(client[kRunning], 0) + client.on('connect', () => { + t.equal(client[kRunning], 0) + process.nextTick(() => { + t.equal(client[kRunning], 1) + client.pipelining = 3 + t.equal(client[kRunning], 2) + }) + }) + }) +}) + +test('destroy in push', (t) => { + t.plan(4) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.error(err) + body.once('data', () => { + _res.write('asd') + body.on('data', (buf) => { + body.destroy() + _res.end() + }).on('error', (err) => { + t.ok(err) + }) + }) + }) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.error(err) + let buf = '' + body.on('data', (chunk) => { + buf = chunk.toString() + _res.end() + }).on('end', () => { + t.equal('asd', buf) + }) + }) + }) +}) + +test('non recoverable socket error fails pending request', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.equal(err.message, 'kaboom') + }) + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.equal(err.message, 'kaboom') + }) + client.on('connect', () => { + client[kSocket].destroy(new Error('kaboom')) + }) + }) +}) + +test('POST empty with error', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const body = new Readable({ + read () { + } + }) + body.push(null) + client.on('connect', () => { + process.nextTick(() => { + body.emit('error', new Error('asd')) + }) + }) + + client.request({ path: '/', method: 'POST', body }, (err, data) => { + t.equal(err.message, 'asd') + }) + }) +}) + +test('busy', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + t.equal(client[kBusy], true) + }) + }) +}) + +test('connected', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + // needed so that disconnect is emitted + res.setHeader('connection', 'close') + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const url = new URL(`http://localhost:${server.address().port}`) + const client = new Client(url, { + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client.on('connect', (origin, [self]) => { + t.equal(origin, url) + t.equal(client, self) + }) + client.on('disconnect', (origin, [self]) => { + t.equal(origin, url) + t.equal(client, self) + }) + + t.equal(client[kConnected], false) + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + t.equal(client[kConnected], true) + }) + }) +}) + +test('emit disconnect after destroy', t => { + t.plan(4) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const url = new URL(`http://localhost:${server.address().port}`) + const client = new Client(url) + + t.equal(client[kConnected], false) + client[kConnect](() => { + t.equal(client[kConnected], true) + let disconnected = false + client.on('disconnect', () => { + disconnected = true + t.pass() + }) + client.destroy(() => { + t.equal(disconnected, true) + }) + }) + }) +}) + +test('end response before request', t => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + const readable = new Readable({ + read () { + this.push('asd') + } + }) + const { body } = await client.request({ + method: 'GET', + path: '/', + body: readable + }) + body + .on('error', () => { + t.fail() + }) + .on('end', () => { + t.pass() + }) + .resume() + client.on('disconnect', (url, targets, err) => { + t.equal(err.code, 'UND_ERR_INFO') + }) + }) +}) + +test('parser pause with no body timeout', (t) => { + t.plan(2) + const server = createServer((req, res) => { + let counter = 0 + const t = setInterval(() => { + counter++ + const payload = Buffer.alloc(counter * 4096).fill(0) + if (counter === 3) { + clearInterval(t) + res.end(payload) + } else { + res.write(payload) + } + }, 20) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + body.resume() + }) + }) +}) + +test('TypedArray and DataView body', (t) => { + t.plan(3) + const server = createServer((req, res) => { + t.equal(req.headers['content-length'], '8') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + + const body = Uint8Array.from(Buffer.alloc(8)) + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + body.resume() + }) + }) +}) + +test('async iterator empty chunk continues', (t) => { + t.plan(5) + const serverChunks = ['hello', 'world'] + const server = createServer((req, res) => { + let str = '' + let i = 0 + req.on('data', (chunk) => { + const content = chunk.toString() + t.equal(serverChunks[i++], content) + str += content + }).on('end', () => { + t.equal(str, serverChunks.join('')) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + + const body = (async function * () { + yield serverChunks[0] + yield '' + yield serverChunks[1] + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + body.resume() + }) + }) +}) + +test('async iterator error from server destroys early', (t) => { + t.plan(3) + const server = createServer((req, res) => { + req.on('data', (chunk) => { + res.destroy() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + let gotDestroyed + const body = (async function * () { + try { + const promise = new Promise(resolve => { + gotDestroyed = resolve + }) + yield 'hello' + await promise + yield 'inner-value' + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true) + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ok(err) + t.equal(statusCode, undefined) + gotDestroyed() + }) + }) +}) + +test('regular iterator error from server closes early', (t) => { + t.plan(3) + const server = createServer((req, res) => { + req.on('data', () => { + process.nextTick(() => { + res.destroy() + }) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + let gotDestroyed = false + const body = (function * () { + try { + yield 'start' + while (!gotDestroyed) { + yield 'zzz' + // for eslint + gotDestroyed = gotDestroyed || false + } + yield 'zzz' + t.fail('should not get here, iterator should be destroyed') + yield 'zzz' + } finally { + t.ok(true) + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ok(err) + t.equal(statusCode, undefined) + gotDestroyed = true + }) + }) +}) + +test('async iterator early return closes early', (t) => { + t.plan(3) + const server = createServer((req, res) => { + req.on('data', () => { + res.writeHead(200) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + let gotDestroyed + const body = (async function * () { + try { + const promise = new Promise(resolve => { + gotDestroyed = resolve + }) + yield 'hello' + await promise + yield 'inner-value' + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true) + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + gotDestroyed() + }) + }) +}) + +test('async iterator yield unsupported TypedArray', (t) => { + t.plan(3) + const server = createServer((req, res) => { + req.on('end', () => { + res.writeHead(200) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + const body = (async function * () { + try { + yield new Int32Array([1]) + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true) + } + })() + client.request({ path: '/', method: 'POST', body }, (err) => { + t.ok(err) + t.equal(err.code, 'ERR_INVALID_ARG_TYPE') + }) + }) +}) + +test('async iterator yield object error', (t) => { + t.plan(3) + const server = createServer((req, res) => { + req.on('end', () => { + res.writeHead(200) + res.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + t.teardown(client.close.bind(client)) + const body = (async function * () { + try { + yield {} + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true) + } + })() + client.request({ path: '/', method: 'POST', body }, (err) => { + t.ok(err) + t.equal(err.code, 'ERR_INVALID_ARG_TYPE') + }) + }) +}) + +function buildParams (path) { + const cleanPath = path.replace('/?', '').replace('/', '').split('&') + const builtParams = cleanPath.reduce((acc, entry) => { + const [key, value] = entry.split('=') + if (key.length === 0) { + return acc + } + + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value) + } else { + acc[key] = [acc[key], value] + } + } else { + acc[key] = value + } + return acc + }, {}) + + return builtParams +} + +test('\\r\\n in Headers', (t) => { + t.plan(1) + + const reqHeaders = { + bar: '\r\nbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.equal(err.message, 'invalid bar header') + }) +}) + +test('\\r in Headers', (t) => { + t.plan(1) + + const reqHeaders = { + bar: '\rbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.equal(err.message, 'invalid bar header') + }) +}) + +test('\\n in Headers', (t) => { + t.plan(1) + + const reqHeaders = { + bar: '\nbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.equal(err.message, 'invalid bar header') + }) +}) + +test('\\n in Headers', (t) => { + t.plan(1) + + const reqHeaders = { + '\nbar': 'foo' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.equal(err.message, 'invalid header key') + }) +}) + +test('\\n in Path', (t) => { + t.plan(1) + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/\n', + method: 'GET' + }, (err) => { + t.equal(err.message, 'invalid request path') + }) +}) + +test('\\n in Method', (t) => { + t.plan(1) + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET\n' + }, (err) => { + t.equal(err.message, 'invalid request method') + }) +}) diff --git a/test/close-and-destroy.js b/test/close-and-destroy.js new file mode 100644 index 0000000..bd50ebb --- /dev/null +++ b/test/close-and-destroy.js @@ -0,0 +1,344 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { kSocket, kSize } = require('../lib/core/symbols') + +test('close waits for queued requests to finish', (t) => { + t.plan(16) + + const server = createServer() + + server.on('request', (req, res) => { + t.pass('request received') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, function (err, data) { + onRequest(err, data) + + client.request({ path: '/', method: 'GET' }, onRequest) + client.request({ path: '/', method: 'GET' }, onRequest) + client.request({ path: '/', method: 'GET' }, onRequest) + + // needed because the next element in the queue will be called + // after the current function completes + process.nextTick(function () { + client.close() + }) + }) + }) + + function onRequest (err, { statusCode, headers, body }) { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } +}) + +test('destroy invoked all pending callbacks', (t) => { + t.plan(4) + + const server = createServer() + + server.on('request', (req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body.on('error', (err) => { + t.ok(err) + }).resume() + client.destroy() + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.type(err, errors.ClientDestroyedError) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) +}) + +test('destroy invoked all pending callbacks ticked', (t) => { + t.plan(4) + + const server = createServer() + + server.on('request', (req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.destroy.bind(client)) + + let ticked = false + client.request({ path: '/', method: 'GET' }, (err) => { + t.equal(ticked, true) + t.type(err, errors.ClientDestroyedError) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.equal(ticked, true) + t.type(err, errors.ClientDestroyedError) + }) + client.destroy() + ticked = true + }) +}) + +test('close waits until socket is destroyed', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.end(req.url) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + makeRequest() + + client.once('connect', () => { + let done = false + client[kSocket].on('close', () => { + done = true + }) + client.close((err) => { + t.error(err) + t.equal(client.closed, true) + t.equal(done, true) + }) + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + }) + return client[kSize] <= client.pipelining + } + }) +}) + +test('close should still reconnect', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end(req.url) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + t.ok(makeRequest()) + t.ok(!makeRequest()) + + client.close((err) => { + t.error(err) + t.equal(client.closed, true) + }) + client.once('connect', () => { + client[kSocket].destroy() + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body.resume() + }) + return client[kSize] <= client.pipelining + } + }) +}) + +test('close should call callback once finished', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + setImmediate(function () { + res.end(req.url) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + t.ok(makeRequest()) + t.ok(!makeRequest()) + + client.close((err) => { + t.error(err) + t.equal(client.closed, true) + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + data.body.resume() + }) + return client[kSize] <= client.pipelining + } + }) +}) + +test('closed and destroyed errors', (t) => { + t.plan(4) + + const client = new Client('http://localhost:4000') + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err) + }) + client.close((err) => { + t.error(err) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.type(err, errors.ClientClosedError) + client.destroy() + client.request({ path: '/', method: 'GET' }, (err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) +}) + +test('close after and destroy should error', (t) => { + t.plan(2) + + const client = new Client('http://localhost:4000') + t.teardown(client.destroy.bind(client)) + + client.destroy() + client.close((err) => { + t.type(err, errors.ClientDestroyedError) + }) + client.close().catch((err) => { + t.type(err, errors.ClientDestroyedError) + }) +}) + +test('close socket and reconnect after maxRequestsPerClient reached', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client( + `http://localhost:${server.address().port}`, + { maxRequestsPerClient: 2 } + ) + t.teardown(client.destroy.bind(client)) + + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + t.equal(connections, 2) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) +}) + +test('close socket and reconnect after maxRequestsPerClient reached (async)', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client( + `http://localhost:${server.address().port}`, + { maxRequestsPerClient: 2 } + ) + t.teardown(client.destroy.bind(client)) + + await t.resolves( + Promise.all([ + makeRequest(), + makeRequest(), + makeRequest(), + makeRequest() + ]) + ) + t.equal(connections, 2) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) +}) + +test('should not close socket when no maxRequestsPerClient is provided', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + await t.resolves(makeRequest()) + t.equal(connections, 1) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) +}) diff --git a/test/connect-abort.js b/test/connect-abort.js new file mode 100644 index 0000000..6eb3624 --- /dev/null +++ b/test/connect-abort.js @@ -0,0 +1,28 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { PassThrough } = require('stream') + +test(t => { + t.plan(2) + + const client = new Client('http://localhost:1234', { + connect: (_, cb) => { + client.destroy() + cb(null, new PassThrough({ + destroy (err, cb) { + t.same(err?.name, 'ClientDestroyedError') + cb(null) + } + })) + } + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.same(err?.name, 'ClientDestroyedError') + }) +}) diff --git a/test/connect-errconnect.js b/test/connect-errconnect.js new file mode 100644 index 0000000..defeda3 --- /dev/null +++ b/test/connect-errconnect.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const net = require('net') + +test('connect-connectionError', t => { + t.plan(2) + + const client = new Client('http://localhost:9000') + t.teardown(client.close.bind(client)) + + client.once('connectionError', () => { + t.pass() + }) + + const _err = new Error('kaboom') + net.connect = function (options) { + const socket = new net.Socket(options) + setImmediate(() => { + socket.destroy(_err) + }) + return socket + } + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err, _err) + }) +}) diff --git a/test/connect-timeout.js b/test/connect-timeout.js new file mode 100644 index 0000000..a736a54 --- /dev/null +++ b/test/connect-timeout.js @@ -0,0 +1,68 @@ +'use strict' + +const { test } = require('tap') +const { Client, Pool, errors } = require('..') +const net = require('net') +const sleep = require('atomic-sleep') + +test('priotorise socket errors over timeouts', (t) => { + t.plan(1) + const connectTimeout = 1000 + const client = new Pool('http://foobar.bar:1234', { connectTimeout: 2 }) + + client.request({ method: 'GET', path: '/foobar' }) + .then(() => t.fail()) + .catch((err) => { + t.equal(err.code, 'ENOTFOUND') + }) + + // block for 1s which is enough for the dns lookup to complete and TO to fire + sleep(connectTimeout) +}) + +// never connect +net.connect = function (options) { + return new net.Socket(options) +} + +test('connect-timeout', t => { + t.plan(1) + + const client = new Client('http://localhost:9000', { + connectTimeout: 1e3 + }) + t.teardown(client.close.bind(client)) + + const timeout = setTimeout(() => { + t.fail() + }, 2e3) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.type(err, errors.ConnectTimeoutError) + clearTimeout(timeout) + }) +}) + +test('connect-timeout', t => { + t.plan(1) + + const client = new Pool('http://localhost:9000', { + connectTimeout: 1e3 + }) + t.teardown(client.close.bind(client)) + + const timeout = setTimeout(() => { + t.fail() + }, 2e3) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.type(err, errors.ConnectTimeoutError) + clearTimeout(timeout) + }) +}) diff --git a/test/content-length.js b/test/content-length.js new file mode 100644 index 0000000..9ce7405 --- /dev/null +++ b/test/content-length.js @@ -0,0 +1,445 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const { maybeWrapStream, consts } = require('./utils/async-iterators') + +test('request invalid content-length', (t) => { + t.plan(7) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asd' + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asdasdasdasdasdasda' + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(9) + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(11) + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: ['asd'] + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: ['asasdasdasdd'] + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 4 + }, + body: ['asasdasdasdd'] + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + }) +}) + +function invalidContentLength (bodyType) { + test(`request streaming ${bodyType} invalid content-length`, (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.once('disconnect', () => { + t.pass() + client.once('disconnect', () => { + t.pass() + }) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + }) + }) +} + +invalidContentLength(consts.STREAM) +invalidContentLength(consts.ASYNC_ITERATOR) + +function zeroContentLength (bodyType) { + test(`request ${bodyType} streaming data when content-length=0`, (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + }) + }) +} + +zeroContentLength(consts.STREAM) +zeroContentLength(consts.ASYNC_ITERATOR) + +test('request streaming no body data when content-length=0', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + } + }, (err, data) => { + t.error(err) + data.body + .on('data', () => { + t.fail() + }) + .on('end', () => { + t.pass() + }) + }) + }) +}) + +test('response invalid content length with close', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.writeHead(200, { + 'content-length': 10 + }) + res.end('123') + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 0 + }) + t.teardown(client.destroy.bind(client)) + + client.on('disconnect', (origin, client, err) => { + t.equal(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH') + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body + .on('end', () => { + t.fail() + }) + .on('error', (err) => { + t.equal(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH') + }) + .resume() + }) + }) +}) + +test('request streaming with Readable.from(buf)', (t) => { + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + body: Readable.from(Buffer.from('hello')) + }, (err, data) => { + const chunks = [] + t.error(err) + data.body + .on('data', (chunk) => { + chunks.push(chunk) + }) + .on('end', () => { + t.equal(Buffer.concat(chunks).toString(), 'hello') + t.pass() + t.end() + }) + }) + }) +}) + +test('request DELETE, content-length=0, with body', (t) => { + t.plan(5) + const server = createServer((req, res) => { + res.end() + }) + server.on('request', (req, res) => { + t.equal(req.headers['content-length'], undefined) + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 0 + }, + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + }, (err) => { + t.type(err, errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 0 + } + }, (err, resp) => { + t.equal(resp.headers['content-length'], '0') + t.error(err) + }) + + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('content-length shouldSendContentLength=false', (t) => { + t.plan(15) + const server = createServer((req, res) => { + res.end() + }) + server.on('request', (req, res) => { + switch (req.url) { + case '/put0': + t.equal(req.headers['content-length'], '0') + break + case '/head': + t.equal(req.headers['content-length'], undefined) + break + case '/get': + t.equal(req.headers['content-length'], undefined) + break + } + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/put0', + method: 'PUT', + headers: { + 'content-length': 0 + } + }, (err, resp) => { + t.equal(resp.headers['content-length'], '0') + t.error(err) + }) + + client.request({ + path: '/head', + method: 'HEAD', + headers: { + 'content-length': 10 + } + }, (err, resp) => { + t.equal(resp.headers['content-length'], undefined) + t.error(err) + }) + + client.request({ + path: '/get', + method: 'GET', + headers: { + 'content-length': 0 + } + }, (err) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + }, (err) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asasdasdasdd') + this.push(null) + } + }) + }, (err) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'HEAD', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asasdasdasdd') + this.push(null) + } + }) + }, (err) => { + t.error(err) + }) + + client.on('disconnect', () => { + t.pass() + }) + }) +}) diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js new file mode 100644 index 0000000..70222fa --- /dev/null +++ b/test/cookie/cookies.js @@ -0,0 +1,616 @@ +// MIT License +// +// Copyright 2018-2022 the Deno authors. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +'use strict' + +const { test } = require('tap') +const { + deleteCookie, + getCookies, + getSetCookies, + setCookie, + Headers +} = require('../..') + +// https://raw.githubusercontent.com/denoland/deno_std/b4239898d6c6b4cdbfd659a4ea1838cf4e656336/http/cookie_test.ts + +test('Cookie parser', (t) => { + let headers = new Headers() + t.same(getCookies(headers), {}) + headers = new Headers() + headers.set('Cookie', 'foo=bar') + t.same(getCookies(headers), { foo: 'bar' }) + + headers = new Headers() + headers.set('Cookie', 'full=of ; tasty=chocolate') + t.same(getCookies(headers), { full: 'of ', tasty: 'chocolate' }) + + headers = new Headers() + headers.set('Cookie', 'igot=99; problems=but...') + t.same(getCookies(headers), { igot: '99', problems: 'but...' }) + + headers = new Headers() + headers.set('Cookie', 'PREF=al=en-GB&f1=123; wide=1; SID=123') + t.same(getCookies(headers), { + PREF: 'al=en-GB&f1=123', + wide: '1', + SID: '123' + }) + + t.end() +}) + +test('Cookie Name Validation', (t) => { + const tokens = [ + '"id"', + 'id\t', + 'i\td', + 'i d', + 'i;d', + '{id}', + '[id]', + '"', + 'id\u0091' + ] + const headers = new Headers() + tokens.forEach((name) => { + t.throws( + () => { + setCookie(headers, { + name, + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) + }, + Error + ) + }) + + t.end() +}) + +test('Cookie Value Validation', (t) => { + const tokens = [ + '1f\tWa', + '\t', + '1f Wa', + '1f;Wa', + '"1fWa', + '1f\\Wa', + '1f"Wa', + '"', + '1fWa\u0005', + '1f\u0091Wa' + ] + + const headers = new Headers() + tokens.forEach((value) => { + t.throws( + () => { + setCookie( + headers, + { + name: 'Space', + value, + httpOnly: true, + secure: true, + maxAge: 3 + } + ) + }, + Error, + "RFC2616 cookie 'Space'" + ) + }) + + t.throws( + () => { + setCookie(headers, { + name: 'location', + value: 'United Kingdom' + }) + }, + Error, + "RFC2616 cookie 'location' cannot contain character ' '" + ) + + t.end() +}) + +test('Cookie Path Validation', (t) => { + const path = '/;domain=sub.domain.com' + const headers = new Headers() + t.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + path, + maxAge: 3 + }) + }, + Error, + path + ": Invalid cookie path char ';'" + ) + + t.end() +}) + +test('Cookie Domain Validation', (t) => { + const tokens = ['-domain.com', 'domain.org.', 'domain.org-'] + const headers = new Headers() + tokens.forEach((domain) => { + t.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + domain, + maxAge: 3 + }) + }, + Error, + 'Invalid first/last char in cookie domain: ' + domain + ) + }) + + t.end() +}) + +test('Cookie Delete', (t) => { + let headers = new Headers() + deleteCookie(headers, 'deno') + t.equal( + headers.get('Set-Cookie'), + 'deno=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + domain: 'deno.land', + path: '/' + }) + deleteCookie(headers, 'Space', { domain: '', path: '' }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Domain=deno.land; Path=/, Space=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + + t.end() +}) + +test('Cookie Set', (t) => { + let headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat' }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', secure: true }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', httpOnly: true }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true + }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2 + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 0 + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=0' + ) + + let error = false + headers = new Headers() + try { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: -1 + }) + } catch { + error = true + } + t.ok(error) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; ' + + 'SameSite=Strict' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'unparsed=keyvalue; batman=Bruce' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: Date.UTC(1983, 0, 7, 15, 32) + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { name: '__Secure-Kitty', value: 'Meow' }) + t.equal(headers.get('Set-Cookie'), '__Secure-Kitty=Meow; Secure') + + headers = new Headers() + setCookie(headers, { + name: '__Host-Kitty', + value: 'Meow', + domain: 'deno.land' + }) + t.equal( + headers.get('Set-Cookie'), + '__Host-Kitty=Meow; Secure; Path=/' + ) + + headers = new Headers() + setCookie(headers, { name: 'cookie-1', value: 'value-1', secure: true }) + setCookie(headers, { name: 'cookie-2', value: 'value-2', maxAge: 3600 }) + t.equal( + headers.get('Set-Cookie'), + 'cookie-1=value-1; Secure, cookie-2=value-2; Max-Age=3600' + ) + + headers = new Headers() + setCookie(headers, { name: '', value: '' }) + t.equal(headers.get('Set-Cookie'), null) + + t.end() +}) + +test('Set-Cookie parser', (t) => { + let headers = new Headers({ 'set-cookie': 'Space=Cat' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat' + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; HttpOnly' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + httpOnly: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure; HttpOnly' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=0' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 0 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=-1' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Strict' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; unparsed=keyvalue; batman=Bruce' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow; Secure' }) + t.same(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow', + secure: true + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow' }) + t.same(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/' + }]) + + headers = new Headers({ 'set-cookie': '__Host-Kitty=Meow; Path=/' }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Domain=deno.land; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/not-root' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/not-root' + }]) + + headers = new Headers([ + ['set-cookie', 'cookie-1=value-1; Secure'], + ['set-cookie', 'cookie-2=value-2; Max-Age=3600'] + ]) + t.same(getSetCookies(headers), [ + { name: 'cookie-1', value: 'value-1', secure: true }, + { name: 'cookie-2', value: 'value-2', maxAge: 3600 } + ]) + + headers = new Headers() + t.same(getSetCookies(headers), []) + + t.end() +}) diff --git a/test/cookie/global-headers.js b/test/cookie/global-headers.js new file mode 100644 index 0000000..1d58dce --- /dev/null +++ b/test/cookie/global-headers.js @@ -0,0 +1,70 @@ +'use strict' + +const { test, skip } = require('tap') +const { + deleteCookie, + getCookies, + getSetCookies, + setCookie +} = require('../..') +const { getHeadersList } = require('../../lib/cookies/util') + +/* global Headers */ + +if (!globalThis.Headers) { + skip('No global Headers to test') + process.exit(0) +} + +test('Using global Headers', (t) => { + t.test('deleteCookies', (t) => { + const headers = new Headers() + + t.equal(headers.get('set-cookie'), null) + deleteCookie(headers, 'undici') + t.equal(headers.get('set-cookie'), 'undici=; Expires=Thu, 01 Jan 1970 00:00:00 GMT') + + t.end() + }) + + t.test('getCookies', (t) => { + const headers = new Headers({ + cookie: 'get=cookies; and=attributes' + }) + + t.same(getCookies(headers), { get: 'cookies', and: 'attributes' }) + t.end() + }) + + t.test('getSetCookies', (t) => { + const headers = new Headers({ + 'set-cookie': 'undici=getSetCookies; Secure' + }) + + const supportsCookies = getHeadersList(headers).cookies + + if (!supportsCookies) { + t.same(getSetCookies(headers), []) + } else { + t.same(getSetCookies(headers), [ + { + name: 'undici', + value: 'getSetCookies', + secure: true + } + ]) + } + + t.end() + }) + + t.test('setCookie', (t) => { + const headers = new Headers() + + setCookie(headers, { name: 'undici', value: 'setCookie' }) + t.equal(headers.get('Set-Cookie'), 'undici=setCookie') + t.end() + }) + + t.end() +}) diff --git a/test/diagnostics-channel/connect-error.js b/test/diagnostics-channel/connect-error.js new file mode 100644 index 0000000..f7e842d --- /dev/null +++ b/test/diagnostics-channel/connect-error.js @@ -0,0 +1,61 @@ +'use strict' + +const t = require('tap') + +let diagnosticsChannel + +try { + diagnosticsChannel = require('diagnostics_channel') +} catch { + t.skip('missing diagnostics_channel') + process.exit(0) +} + +const { Client } = require('../..') + +t.plan(16) + +const connectError = new Error('custom error') + +let _connector +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + _connector = connector + + t.equal(typeof _connector, 'function') + t.equal(Object.keys(connectParams).length, 6) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, 'localhost:1234') + t.equal(hostname, 'localhost') + t.equal(port, '1234') + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +diagnosticsChannel.channel('undici:client:connectError').subscribe(({ error, connectParams, connector }) => { + t.equal(Object.keys(connectParams).length, 6) + t.equal(_connector, connector) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(error, connectError) + t.equal(host, 'localhost:1234') + t.equal(hostname, 'localhost') + t.equal(port, '1234') + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +const client = new Client('http://localhost:1234', { + connect: (_, cb) => { cb(connectError, null) } +}) + +t.teardown(client.close.bind(client)) + +client.request({ + path: '/', + method: 'GET' +}, (err, data) => { + t.equal(err, connectError) +}) diff --git a/test/diagnostics-channel/error.js b/test/diagnostics-channel/error.js new file mode 100644 index 0000000..1f350b1 --- /dev/null +++ b/test/diagnostics-channel/error.js @@ -0,0 +1,52 @@ +'use strict' + +const t = require('tap') + +let diagnosticsChannel + +try { + diagnosticsChannel = require('diagnostics_channel') +} catch { + t.skip('missing diagnostics_channel') + process.exit(0) +} + +const { Client } = require('../..') +const { createServer } = require('http') + +t.plan(3) + +const server = createServer((req, res) => { + res.destroy() +}) +t.teardown(server.close.bind(server)) + +const reqHeaders = { + foo: undefined, + bar: 'bar' +} + +let _req +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + _req = request +}) + +diagnosticsChannel.channel('undici:request:error').subscribe(({ request, error }) => { + t.equal(_req, request) + t.equal(error.code, 'UND_ERR_SOCKET') +}) + +server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, data) => { + t.equal(err.code, 'UND_ERR_SOCKET') + }) +}) diff --git a/test/diagnostics-channel/get.js b/test/diagnostics-channel/get.js new file mode 100644 index 0000000..9d868c3 --- /dev/null +++ b/test/diagnostics-channel/get.js @@ -0,0 +1,141 @@ +'use strict' + +const t = require('tap') + +let diagnosticsChannel + +try { + diagnosticsChannel = require('diagnostics_channel') +} catch { + t.skip('missing diagnostics_channel') + process.exit(0) +} + +const { Client } = require('../..') +const { createServer } = require('http') + +t.plan(32) + +const server = createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.setHeader('trailer', 'foo') + res.write('hello') + res.addTrailers({ + foo: 'oof' + }) + res.end() +}) +t.teardown(server.close.bind(server)) + +const reqHeaders = { + foo: undefined, + bar: 'bar' +} + +let _req +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + _req = request + t.equal(request.origin, `http://localhost:${server.address().port}`) + t.equal(request.completed, false) + t.equal(request.method, 'GET') + t.equal(request.path, '/') + t.equal(request.headers, 'bar: bar\r\n') + request.addHeader('hello', 'world') + t.equal(request.headers, 'bar: bar\r\nhello: world\r\n') +}) + +let _connector +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + _connector = connector + + t.equal(typeof _connector, 'function') + t.equal(Object.keys(connectParams).length, 6) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +let _socket +diagnosticsChannel.channel('undici:client:connected').subscribe(({ connectParams, socket, connector }) => { + _socket = socket + + t.equal(_connector, connector) + t.equal(Object.keys(connectParams).length, 6) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => { + t.equal(_req, request) + t.equal(_socket, socket) + + const expectedHeaders = [ + 'GET / HTTP/1.1', + `host: localhost:${server.address().port}`, + 'connection: keep-alive', + 'bar: bar', + 'hello: world' + ] + + t.equal(headers, expectedHeaders.join('\r\n') + '\r\n') +}) + +diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => { + t.equal(_req, request) + t.equal(response.statusCode, 200) + const expectedHeaders = [ + Buffer.from('Content-Type'), + Buffer.from('text/plain'), + Buffer.from('trailer'), + Buffer.from('foo'), + Buffer.from('Date'), + response.headers[5], // This is a date + Buffer.from('Connection'), + Buffer.from('keep-alive'), + Buffer.from('Keep-Alive'), + Buffer.from('timeout=5'), + Buffer.from('Transfer-Encoding'), + Buffer.from('chunked') + ] + t.same(response.headers, expectedHeaders) + t.equal(response.statusText, 'OK') +}) + +let endEmitted = false +diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => { + t.equal(request.completed, true) + t.equal(_req, request) + // This event is emitted after the last chunk has been added to the body stream, + // not when it was consumed by the application + t.equal(endEmitted, false) + t.same(trailers, [Buffer.from('foo'), Buffer.from('oof')]) +}) + +server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, data) => { + t.error(err) + data.body.on('end', function () { + endEmitted = true + }) + }) +}) diff --git a/test/diagnostics-channel/post-stream.js b/test/diagnostics-channel/post-stream.js new file mode 100644 index 0000000..236b9bb --- /dev/null +++ b/test/diagnostics-channel/post-stream.js @@ -0,0 +1,149 @@ +'use strict' + +const t = require('tap') +const { Readable } = require('stream') + +let diagnosticsChannel + +try { + diagnosticsChannel = require('diagnostics_channel') +} catch { + t.skip('missing diagnostics_channel') + process.exit(0) +} + +const { Client } = require('../..') +const { createServer } = require('http') + +t.plan(33) + +const server = createServer((req, res) => { + req.resume() + res.setHeader('Content-Type', 'text/plain') + res.setHeader('trailer', 'foo') + res.write('hello') + res.addTrailers({ + foo: 'oof' + }) + res.end() +}) +t.teardown(server.close.bind(server)) + +const reqHeaders = { + foo: undefined, + bar: 'bar' +} +const body = Readable.from(['hello', ' ', 'world']) + +let _req +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + _req = request + t.equal(request.completed, false) + t.equal(request.method, 'POST') + t.equal(request.path, '/') + t.equal(request.headers, 'bar: bar\r\n') + request.addHeader('hello', 'world') + t.equal(request.headers, 'bar: bar\r\nhello: world\r\n') + t.same(request.body, body) +}) + +let _connector +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + _connector = connector + + t.equal(typeof _connector, 'function') + t.equal(Object.keys(connectParams).length, 6) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +let _socket +diagnosticsChannel.channel('undici:client:connected').subscribe(({ connectParams, socket, connector }) => { + _socket = socket + + t.equal(Object.keys(connectParams).length, 6) + t.equal(_connector, connector) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => { + t.equal(_req, request) + t.equal(_socket, socket) + + const expectedHeaders = [ + 'POST / HTTP/1.1', + `host: localhost:${server.address().port}`, + 'connection: keep-alive', + 'bar: bar', + 'hello: world' + ] + + t.equal(headers, expectedHeaders.join('\r\n') + '\r\n') +}) + +diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => { + t.equal(_req, request) + t.equal(response.statusCode, 200) + const expectedHeaders = [ + Buffer.from('Content-Type'), + Buffer.from('text/plain'), + Buffer.from('trailer'), + Buffer.from('foo'), + Buffer.from('Date'), + response.headers[5], // This is a date + Buffer.from('Connection'), + Buffer.from('keep-alive'), + Buffer.from('Keep-Alive'), + Buffer.from('timeout=5'), + Buffer.from('Transfer-Encoding'), + Buffer.from('chunked') + ] + t.same(response.headers, expectedHeaders) + t.equal(response.statusText, 'OK') +}) + +diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) => { + t.equal(_req, request) +}) + +let endEmitted = false +diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => { + t.equal(request.completed, true) + t.equal(_req, request) + // This event is emitted after the last chunk has been added to the body stream, + // not when it was consumed by the application + t.equal(endEmitted, false) + t.same(trailers, [Buffer.from('foo'), Buffer.from('oof')]) +}) + +server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: reqHeaders, + body + }, (err, data) => { + t.error(err) + data.body.on('end', function () { + endEmitted = true + }) + }) +}) diff --git a/test/diagnostics-channel/post.js b/test/diagnostics-channel/post.js new file mode 100644 index 0000000..fc02eb5 --- /dev/null +++ b/test/diagnostics-channel/post.js @@ -0,0 +1,147 @@ +'use strict' + +const t = require('tap') + +let diagnosticsChannel + +try { + diagnosticsChannel = require('diagnostics_channel') +} catch { + t.skip('missing diagnostics_channel') + process.exit(0) +} + +const { Client } = require('../..') +const { createServer } = require('http') + +t.plan(33) + +const server = createServer((req, res) => { + req.resume() + res.setHeader('Content-Type', 'text/plain') + res.setHeader('trailer', 'foo') + res.write('hello') + res.addTrailers({ + foo: 'oof' + }) + res.end() +}) +t.teardown(server.close.bind(server)) + +const reqHeaders = { + foo: undefined, + bar: 'bar' +} + +let _req +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + _req = request + t.equal(request.completed, false) + t.equal(request.method, 'POST') + t.equal(request.path, '/') + t.equal(request.headers, 'bar: bar\r\n') + request.addHeader('hello', 'world') + t.equal(request.headers, 'bar: bar\r\nhello: world\r\n') + t.same(request.body, Buffer.from('hello world')) +}) + +let _connector +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + _connector = connector + + t.equal(typeof _connector, 'function') + t.equal(Object.keys(connectParams).length, 6) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +let _socket +diagnosticsChannel.channel('undici:client:connected').subscribe(({ connectParams, socket, connector }) => { + _socket = socket + + t.equal(Object.keys(connectParams).length, 6) + t.equal(_connector, connector) + + const { host, hostname, protocol, port, servername } = connectParams + + t.equal(host, `localhost:${server.address().port}`) + t.equal(hostname, 'localhost') + t.equal(port, String(server.address().port)) + t.equal(protocol, 'http:') + t.equal(servername, null) +}) + +diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => { + t.equal(_req, request) + t.equal(_socket, socket) + + const expectedHeaders = [ + 'POST / HTTP/1.1', + `host: localhost:${server.address().port}`, + 'connection: keep-alive', + 'bar: bar', + 'hello: world' + ] + + t.equal(headers, expectedHeaders.join('\r\n') + '\r\n') +}) + +diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => { + t.equal(_req, request) + t.equal(response.statusCode, 200) + const expectedHeaders = [ + Buffer.from('Content-Type'), + Buffer.from('text/plain'), + Buffer.from('trailer'), + Buffer.from('foo'), + Buffer.from('Date'), + response.headers[5], // This is a date + Buffer.from('Connection'), + Buffer.from('keep-alive'), + Buffer.from('Keep-Alive'), + Buffer.from('timeout=5'), + Buffer.from('Transfer-Encoding'), + Buffer.from('chunked') + ] + t.same(response.headers, expectedHeaders) + t.equal(response.statusText, 'OK') +}) + +diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) => { + t.equal(_req, request) +}) + +let endEmitted = false +diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => { + t.equal(request.completed, true) + t.equal(_req, request) + // This event is emitted after the last chunk has been added to the body stream, + // not when it was consumed by the application + t.equal(endEmitted, false) + t.same(trailers, [Buffer.from('foo'), Buffer.from('oof')]) +}) + +server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'POST', + headers: reqHeaders, + body: 'hello world' + }, (err, data) => { + t.error(err) + data.body.on('end', function () { + endEmitted = true + }) + }) +}) diff --git a/test/dispatcher.js b/test/dispatcher.js new file mode 100644 index 0000000..22750a1 --- /dev/null +++ b/test/dispatcher.js @@ -0,0 +1,22 @@ +'use strict' + +const t = require('tap') +const { test } = t + +const Dispatcher = require('../lib/dispatcher') + +class PoorImplementation extends Dispatcher {} + +test('dispatcher implementation', (t) => { + t.plan(6) + + const dispatcher = new Dispatcher() + t.throws(() => dispatcher.dispatch(), Error, 'throws on unimplemented dispatch') + t.throws(() => dispatcher.close(), Error, 'throws on unimplemented close') + t.throws(() => dispatcher.destroy(), Error, 'throws on unimplemented destroy') + + const poorImplementation = new PoorImplementation() + t.throws(() => poorImplementation.dispatch(), Error, 'throws on unimplemented dispatch') + t.throws(() => poorImplementation.close(), Error, 'throws on unimplemented close') + t.throws(() => poorImplementation.destroy(), Error, 'throws on unimplemented destroy') +}) diff --git a/test/errors.js b/test/errors.js new file mode 100644 index 0000000..a6f17ef --- /dev/null +++ b/test/errors.js @@ -0,0 +1,81 @@ +'use strict' + +const t = require('tap') +const { test } = t + +const errors = require('../lib/core/errors') + +const createScenario = (ErrorClass, defaultMessage, name, code) => ({ + ErrorClass, + defaultMessage, + name, + code +}) + +const scenarios = [ + createScenario(errors.UndiciError, '', 'UndiciError', 'UND_ERR'), + createScenario(errors.ConnectTimeoutError, 'Connect Timeout Error', 'ConnectTimeoutError', 'UND_ERR_CONNECT_TIMEOUT'), + createScenario(errors.HeadersTimeoutError, 'Headers Timeout Error', 'HeadersTimeoutError', 'UND_ERR_HEADERS_TIMEOUT'), + createScenario(errors.HeadersOverflowError, 'Headers Overflow Error', 'HeadersOverflowError', 'UND_ERR_HEADERS_OVERFLOW'), + createScenario(errors.InvalidArgumentError, 'Invalid Argument Error', 'InvalidArgumentError', 'UND_ERR_INVALID_ARG'), + createScenario(errors.InvalidReturnValueError, 'Invalid Return Value Error', 'InvalidReturnValueError', 'UND_ERR_INVALID_RETURN_VALUE'), + createScenario(errors.RequestAbortedError, 'Request aborted', 'AbortError', 'UND_ERR_ABORTED'), + createScenario(errors.InformationalError, 'Request information', 'InformationalError', 'UND_ERR_INFO'), + createScenario(errors.RequestContentLengthMismatchError, 'Request body length does not match content-length header', 'RequestContentLengthMismatchError', 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'), + createScenario(errors.ClientDestroyedError, 'The client is destroyed', 'ClientDestroyedError', 'UND_ERR_DESTROYED'), + createScenario(errors.ClientClosedError, 'The client is closed', 'ClientClosedError', 'UND_ERR_CLOSED'), + createScenario(errors.SocketError, 'Socket error', 'SocketError', 'UND_ERR_SOCKET'), + createScenario(errors.NotSupportedError, 'Not supported error', 'NotSupportedError', 'UND_ERR_NOT_SUPPORTED'), + createScenario(errors.ResponseContentLengthMismatchError, 'Response body length does not match content-length header', 'ResponseContentLengthMismatchError', 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH'), + createScenario(errors.ResponseExceededMaxSizeError, 'Response content exceeded max size', 'ResponseExceededMaxSizeError', 'UND_ERR_RES_EXCEEDED_MAX_SIZE') +] + +scenarios.forEach(scenario => { + test(scenario.name, t => { + const SAMPLE_MESSAGE = 'sample message' + + const errorWithDefaultMessage = () => new scenario.ErrorClass() + const errorWithProvidedMessage = () => new scenario.ErrorClass(SAMPLE_MESSAGE) + + test('should use default message', t => { + t.plan(1) + + const error = errorWithDefaultMessage() + + t.equal(error.message, scenario.defaultMessage) + }) + + test('should use provided message', t => { + t.plan(1) + + const error = errorWithProvidedMessage() + + t.equal(error.message, SAMPLE_MESSAGE) + }) + + test('should have proper fields', t => { + t.plan(6) + const errorInstances = [errorWithDefaultMessage(), errorWithProvidedMessage()] + errorInstances.forEach(error => { + t.equal(error.name, scenario.name) + t.equal(error.code, scenario.code) + t.ok(error.stack) + }) + }) + + t.end() + }) +}) + +test('Default HTTPParseError Codes', t => { + test('code and data should be undefined when not set', t => { + t.plan(2) + + const error = new errors.HTTPParserError('HTTPParserError') + + t.equal(error.code, undefined) + t.equal(error.data, undefined) + }) + + t.end() +}) diff --git a/test/esm-wrapper.js b/test/esm-wrapper.js new file mode 100644 index 0000000..a593fbd --- /dev/null +++ b/test/esm-wrapper.js @@ -0,0 +1,19 @@ +'use strict' +const { nodeMajor, nodeMinor } = require('../lib/core/util') + +if (!((nodeMajor > 14 || (nodeMajor === 14 && nodeMajor > 13)) || (nodeMajor === 12 && nodeMinor > 20))) { + require('tap') // shows skipped +} else { + ;(async () => { + try { + await import('./utils/esm-wrapper.mjs') + } catch (e) { + if (e.message === 'Not supported') { + require('tap') // shows skipped + return + } + console.error(e.stack) + process.exitCode = 1 + } + })() +} diff --git a/test/fetch/407-statuscode-window-null.js b/test/fetch/407-statuscode-window-null.js new file mode 100644 index 0000000..e22554f --- /dev/null +++ b/test/fetch/407-statuscode-window-null.js @@ -0,0 +1,20 @@ +'use strict' + +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { test } = require('tap') + +test('Receiving a 407 status code w/ a window option present should reject', async (t) => { + const server = createServer((req, res) => { + res.statusCode = 407 + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // if init.window exists, the spec tells us to set request.window to 'no-window', + // which later causes the request to be rejected if the status code is 407 + await t.rejects(fetch(`http://localhost:${server.address().port}`, { window: null })) +}) diff --git a/test/fetch/abort.js b/test/fetch/abort.js new file mode 100644 index 0000000..e1ca1eb --- /dev/null +++ b/test/fetch/abort.js @@ -0,0 +1,82 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { DOMException } = require('../../lib/fetch/constants') +const { nodeMajor } = require('../../lib/core/util') + +const { AbortController: NPMAbortController } = require('abort-controller') + +test('Allow the usage of custom implementation of AbortController', async (t) => { + const body = { + fixes: 1605 + } + + const server = createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify(body)) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0) + await once(server, 'listening') + + const controller = new NPMAbortController() + const signal = controller.signal + controller.abort() + + try { + await fetch(`http://localhost:${server.address().port}`, { + signal + }) + } catch (e) { + t.equal(e.code, DOMException.ABORT_ERR) + } +}) + +test('allows aborting with custom errors', { skip: nodeMajor === 16 }, async (t) => { + const server = createServer().listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + t.test('Using AbortSignal.timeout with cause', async (t) => { + t.plan(2) + + try { + await fetch(`http://localhost:${server.address().port}`, { + signal: AbortSignal.timeout(50) + }) + t.fail('should throw') + } catch (err) { + if (err.name === 'TypeError') { + const cause = err.cause + t.equal(cause.name, 'HeadersTimeoutError') + t.equal(cause.code, 'UND_ERR_HEADERS_TIMEOUT') + } else if (err.name === 'TimeoutError') { + t.equal(err.code, DOMException.TIMEOUT_ERR) + t.equal(err.cause, undefined) + } else { + t.error(err) + } + } + }) + + t.test('Error defaults to an AbortError DOMException', async (t) => { + const ac = new AbortController() + ac.abort() // no reason + + await t.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal + }), + { + name: 'AbortError', + code: DOMException.ABORT_ERR + } + ) + }) +}) diff --git a/test/fetch/abort2.js b/test/fetch/abort2.js new file mode 100644 index 0000000..5f3853b --- /dev/null +++ b/test/fetch/abort2.js @@ -0,0 +1,60 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { DOMException } = require('../../lib/fetch/constants') + +/* global AbortController */ + +test('parallel fetch with the same AbortController works as expected', async (t) => { + const body = { + fixes: 1389, + bug: 'Ensure request is not aborted before enqueueing bytes into stream.' + } + + const server = createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify(body)) + }) + + t.teardown(server.close.bind(server)) + + const abortController = new AbortController() + + async function makeRequest () { + const result = await fetch(`http://localhost:${server.address().port}`, { + signal: abortController.signal + }).then(response => response.json()) + + abortController.abort() + return result + } + + server.listen(0) + await once(server, 'listening') + + const requests = Array.from({ length: 10 }, makeRequest) + const result = await Promise.allSettled(requests) + + // since the requests are running parallel, any of them could resolve first. + // therefore we cannot rely on the order of the requests sent. + const { resolved, rejected } = result.reduce((a, b) => { + if (b.status === 'rejected') { + a.rejected.push(b) + } else { + a.resolved.push(b) + } + + return a + }, { resolved: [], rejected: [] }) + + t.equal(rejected.length, 9) // out of 10 requests, only 1 should succeed + t.equal(resolved.length, 1) + + t.ok(rejected.every(rej => rej.reason?.code === DOMException.ABORT_ERR)) + t.same(resolved[0].value, body) + + t.end() +}) diff --git a/test/fetch/about-uri.js b/test/fetch/about-uri.js new file mode 100644 index 0000000..ac9cbf2 --- /dev/null +++ b/test/fetch/about-uri.js @@ -0,0 +1,21 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') + +test('fetching about: uris', async (t) => { + t.test('about:blank', async (t) => { + await t.rejects(fetch('about:blank')) + }) + + t.test('All other about: urls should return an error', async (t) => { + try { + await fetch('about:config') + t.fail('fetching about:config should fail') + } catch (e) { + t.ok(e, 'this error was expected') + } finally { + t.end() + } + }) +}) diff --git a/test/fetch/blob-uri.js b/test/fetch/blob-uri.js new file mode 100644 index 0000000..f9db96c --- /dev/null +++ b/test/fetch/blob-uri.js @@ -0,0 +1,100 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { Blob } = require('buffer') + +test('fetching blob: uris', async (t) => { + const blobContents = 'hello world' + /** @type {import('buffer').Blob} */ + let blob + /** @type {string} */ + let objectURL + + t.beforeEach(() => { + blob = new Blob([blobContents]) + objectURL = URL.createObjectURL(blob) + }) + + t.test('a normal fetch request works', async (t) => { + const res = await fetch(objectURL) + + t.equal(blobContents, await res.text()) + t.equal(blob.type, res.headers.get('Content-Type')) + t.equal(`${blob.size}`, res.headers.get('Content-Length')) + t.end() + }) + + t.test('non-GET method to blob: fails', async (t) => { + try { + await fetch(objectURL, { + method: 'POST' + }) + t.fail('expected POST to blob: uri to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L36-L41 + t.test('fetching revoked URL should fail', async (t) => { + URL.revokeObjectURL(objectURL) + + try { + await fetch(objectURL) + t.fail('expected revoked blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L28-L34 + t.test('works with a fragment', async (t) => { + const res = await fetch(objectURL + '#fragment') + + t.equal(blobContents, await res.text()) + t.end() + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 + t.test('Appending a query string to blob: url should cause fetch to fail', async (t) => { + try { + await fetch(objectURL + '?querystring') + t.fail('expected ?querystring blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L58-L62 + t.test('Appending a path should cause fetch to fail', async (t) => { + try { + await fetch(objectURL + '/path') + t.fail('expected /path blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L64-L70 + t.test('these http methods should fail', async (t) => { + for (const method of ['HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'CUSTOM']) { + try { + await fetch(objectURL, { method }) + t.fail(`${method} fetch should have failed`) + } catch (e) { + t.ok(e, `${method} blob url - test succeeded`) + } + } + + t.end() + }) +}) diff --git a/test/fetch/bundle.js b/test/fetch/bundle.js new file mode 100644 index 0000000..aa1257a --- /dev/null +++ b/test/fetch/bundle.js @@ -0,0 +1,41 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const { Response, Request, FormData, Headers } = require('../../undici-fetch') + +test('bundle sets constructor.name and .name properly', (t) => { + t.equal(new Response().constructor.name, 'Response') + t.equal(Response.name, 'Response') + + t.equal(new Request('http://a').constructor.name, 'Request') + t.equal(Request.name, 'Request') + + t.equal(new Headers().constructor.name, 'Headers') + t.equal(Headers.name, 'Headers') + + t.equal(new FormData().constructor.name, 'FormData') + t.equal(FormData.name, 'FormData') + + t.end() +}) + +test('regression test for https://github.com/nodejs/node/issues/50263', (t) => { + const request = new Request('https://a', { + headers: { + test: 'abc' + }, + method: 'POST' + }) + + const request1 = new Request(request, { body: 'does not matter' }) + + t.equal(request1.headers.get('test'), 'abc') + t.end() +}) diff --git a/test/fetch/client-error-stack-trace.js b/test/fetch/client-error-stack-trace.js new file mode 100644 index 0000000..7d94aa8 --- /dev/null +++ b/test/fetch/client-error-stack-trace.js @@ -0,0 +1,21 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { fetch: fetchIndex } = require('../../index-fetch') + +test('FETCH: request errors and prints trimmed stack trace', async (t) => { + try { + await fetch('http://a.com') + } catch (error) { + t.match(error.stack, `at Test. (${__filename}`) + } +}) + +test('FETCH-index: request errors and prints trimmed stack trace', async (t) => { + try { + await fetchIndex('http://a.com') + } catch (error) { + t.match(error.stack, `at Test. (${__filename}`) + } +}) diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js new file mode 100644 index 0000000..9009d54 --- /dev/null +++ b/test/fetch/client-fetch.js @@ -0,0 +1,688 @@ +/* globals AbortController */ + +'use strict' + +const { test, teardown } = require('tap') +const { createServer } = require('http') +const { ReadableStream } = require('stream/web') +const { Blob } = require('buffer') +const { fetch, Response, Request, FormData, File } = require('../..') +const { Client, setGlobalDispatcher, Agent } = require('../..') +const { nodeMajor, nodeMinor } = require('../../lib/core/util') +const nodeFetch = require('../../index-fetch') +const { once } = require('events') +const { gzipSync } = require('zlib') +const { promisify } = require('util') +const { randomFillSync, createHash } = require('crypto') + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('function signature', (t) => { + t.plan(2) + + t.equal(fetch.name, 'fetch') + t.equal(fetch.length, 1) +}) + +test('args validation', async (t) => { + t.plan(2) + + await t.rejects(fetch(), TypeError) + await t.rejects(fetch('ftp://unsupported'), TypeError) +}) + +test('request json', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(obj, await body.json()) + }) +}) + +test('request text', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('request arrayBuffer', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(Buffer.from(JSON.stringify(obj)), Buffer.from(await body.arrayBuffer())) + }) +}) + +test('should set type of blob object to the value of the `Content-Type` header from response', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`) + t.equal('application/json', (await response.blob()).type) + }) +}) + +test('pre aborted with readable request body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + await fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body: new ReadableStream({ + async cancel (reason) { + t.equal(reason.name, 'AbortError') + } + }), + duplex: 'half' + }).catch(err => { + t.equal(err.name, 'AbortError') + }) + }) +}) + +test('pre aborted with closed readable request body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + const body = new ReadableStream({ + async start (c) { + t.pass() + c.close() + }, + async cancel (reason) { + t.fail() + } + }) + queueMicrotask(() => { + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body, + duplex: 'half' + }).catch(err => { + t.equal(err.name, 'AbortError') + }) + }) + }) +}) + +test('unsupported formData 1', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'asdasdsad') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .catch(err => { + t.equal(err.name, 'TypeError') + }) + }) +}) + +test('multipart formdata not base64', async (t) => { + t.plan(2) + // Construct example form data, with text and blob fields + const formData = new FormData() + formData.append('field1', 'value1') + const blob = new Blob(['example\ntext file'], { type: 'text/plain' }) + formData.append('field2', blob, 'file.txt') + + const tempRes = new Response(formData) + const boundary = tempRes.headers.get('content-type').split('boundary=')[1] + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary) + res.write(formRaw) + res.end() + }) + t.teardown(server.close.bind(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + const form = await res.formData() + t.equal(form.get('field1'), 'value1') + + const text = await form.get('field2').text() + t.equal(text, 'example\ntext file') +}) + +// TODO(@KhafraDev): re-enable this test once the issue is fixed +// See https://github.com/nodejs/node/issues/47301 +test('multipart formdata base64', { skip: nodeMajor >= 19 && nodeMinor >= 8 }, (t) => { + t.plan(1) + + // Example form data with base64 encoding + const data = randomFillSync(Buffer.alloc(256)) + const formRaw = `------formdata-undici-0.5786922755719377\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n${data.toString('base64')}\r\n------formdata-undici-0.5786922755719377--` + const server = createServer(async (req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377') + + for (let offset = 0; offset < formRaw.length;) { + res.write(formRaw.slice(offset, offset += 2)) + await new Promise(resolve => setTimeout(resolve)) + } + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(form => form.get('file').arrayBuffer()) + .then(buffer => createHash('sha256').update(Buffer.from(buffer)).digest('base64')) + .then(digest => { + t.equal(createHash('sha256').update(data).digest('base64'), digest) + }) + }) +}) + +test('multipart fromdata non-ascii filed names', async (t) => { + t.plan(1) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=----formdata-undici-0.6204674738279623' + }, + body: + '------formdata-undici-0.6204674738279623\r\n' + + 'Content-Disposition: form-data; name="fiŝo"\r\n' + + '\r\n' + + 'value1\r\n' + + '------formdata-undici-0.6204674738279623--' + }) + + const form = await request.formData() + t.equal(form.get('fiŝo'), 'value1') +}) + +test('busboy emit error', async (t) => { + t.plan(1) + const formData = new FormData() + formData.append('field1', 'value1') + + const tempRes = new Response(formData) + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary') + res.write(formRaw) + res.end() + }) + t.teardown(server.close.bind(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + await t.rejects(res.formData(), 'Unexpected end of multipart data') +}) + +// https://github.com/nodejs/undici/issues/2244 +test('parsing formData preserve full path on files', async (t) => { + t.plan(1) + const formData = new FormData() + formData.append('field1', new File(['foo'], 'a/b/c/foo.txt')) + + const tempRes = new Response(formData) + const form = await tempRes.formData() + + t.equal(form.get('field1').name, 'a/b/c/foo.txt') +}) + +test('urlencoded formData', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('field1=value1&field2=value2') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + t.equal(formData.get('field1'), 'value1') + t.equal(formData.get('field2'), 'value2') + }) + }) +}) + +test('text with BOM', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.text()) + .then(text => { + t.equal(text, 'test=\uFEFF') + }) + }) +}) + +test('formData with BOM', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + t.equal(formData.get('\uFEFFtest'), '\uFEFF') + }) + }) +}) + +test('locked blob body', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + const reader = res.body.getReader() + res.blob().catch(err => { + t.equal(err.message, 'Body is unusable') + reader.cancel() + }) + }) +}) + +test('disturbed blob body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + res.blob().then(() => { + t.pass(2) + }) + res.blob().catch(err => { + t.equal(err.message, 'Body is unusable') + }) + }) +}) + +test('redirect with body', (t) => { + t.plan(3) + + let count = 0 + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) { + body += chunk + } + t.equal(body, 'asd') + if (count++ === 0) { + res.setHeader('location', 'asd') + res.statusCode = 302 + res.end() + } else { + res.end(String(count)) + } + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body: 'asd' + }) + t.equal(await res.text(), '2') + }) +}) + +test('redirect with stream', (t) => { + t.plan(3) + + const location = '/asd' + const body = 'hello!' + const server = createServer(async (req, res) => { + res.writeHead(302, { location }) + let count = 0 + const l = setInterval(() => { + res.write(body[count++]) + if (count === body.length) { + res.end() + clearInterval(l) + } + }, 50) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'manual' + }) + t.equal(res.status, 302) + t.equal(res.headers.get('location'), location) + t.equal(await res.text(), body) + }) +}) + +test('fail to extract locked body', (t) => { + t.plan(1) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Response(stream) + } catch (err) { + t.equal(err.name, 'TypeError') + } + reader.cancel() +}) + +test('fail to extract locked body', (t) => { + t.plan(1) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Request('http://asd', { + method: 'PUT', + body: stream, + keepalive: true + }) + } catch (err) { + t.equal(err.message, 'keepalive') + } + reader.cancel() +}) + +test('post FormData with Blob', (t) => { + t.plan(1) + + const body = new FormData() + body.append('field1', new Blob(['asd1'])) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + t.ok(/asd1/.test(await res.text())) + }) +}) + +test('post FormData with File', (t) => { + t.plan(2) + + const body = new FormData() + body.append('field1', new File(['asd1'], 'filename123')) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + const result = await res.text() + t.ok(/asd1/.test(result)) + t.ok(/filename123/.test(result)) + }) +}) + +test('invalid url', async (t) => { + t.plan(1) + + try { + await fetch('http://invalid') + } catch (e) { + t.match(e.cause.message, 'invalid') + } +}) + +test('custom agent', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + t.pass('custom dispatcher') + return oldDispatch.call(this, options, handler) + } + t.teardown(server.close.bind(server)) + const body = await fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('custom agent node fetch', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + t.pass('custom dispatcher') + return oldDispatch.call(this, options, handler) + } + t.teardown(server.close.bind(server)) + const body = await nodeFetch.fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('error on redirect', async (t) => { + const server = createServer((req, res) => { + res.statusCode = 302 + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const errorCause = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'error' + }).catch((e) => e.cause) + + t.equal(errorCause.message, 'unexpected redirect') + }) +}) + +// https://github.com/nodejs/undici/issues/1527 +test('fetching with Request object - issue #1527', async (t) => { + const server = createServer((req, res) => { + t.pass() + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const body = JSON.stringify({ foo: 'bar' }) + const request = new Request(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) + + await t.resolves(fetch(request)) + t.end() +}) + +test('do not decode redirect body', (t) => { + t.plan(3) + + const obj = { asd: true } + const server = createServer((req, res) => { + if (req.url === '/resource') { + t.pass('we redirect') + res.statusCode = 301 + res.setHeader('location', '/resource/') + // Some dumb http servers set the content-encoding gzip + // even if there is no response + res.setHeader('content-encoding', 'gzip') + res.end() + return + } + t.pass('actual response') + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('decode non-redirect body with location header', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + t.pass('response') + res.statusCode = 201 + res.setHeader('location', '/resource/') + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('Receiving non-Latin1 headers', async (t) => { + const ContentDisposition = [ + 'inline; filename=rock&roll.png', + 'inline; filename="rock\'n\'roll.png"', + 'inline; filename="image â\x80\x94 copy (1).png"; filename*=UTF-8\'\'image%20%E2%80%94%20copy%20(1).png', + 'inline; filename="_å\x9C\x96ç\x89\x87_ð\x9F\x96¼_image_.png"; filename*=UTF-8\'\'_%E5%9C%96%E7%89%87_%F0%9F%96%BC_image_.png', + 'inline; filename="100 % loading&perf.png"; filename*=UTF-8\'\'100%20%25%20loading%26perf.png' + ] + + const server = createServer((req, res) => { + for (let i = 0; i < ContentDisposition.length; i++) { + res.setHeader(`Content-Disposition-${i + 1}`, ContentDisposition[i]) + } + + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const url = `http://localhost:${server.address().port}` + const response = await fetch(url, { method: 'HEAD' }) + const cdHeaders = [...response.headers] + .filter(([k]) => k.startsWith('content-disposition')) + .map(([, v]) => v) + const lengths = cdHeaders.map(h => h.length) + + t.same(cdHeaders, ContentDisposition) + t.same(lengths, [30, 34, 94, 104, 90]) + t.end() +}) + +teardown(() => process.exit()) diff --git a/test/fetch/client-node-max-header-size.js b/test/fetch/client-node-max-header-size.js new file mode 100644 index 0000000..737bae8 --- /dev/null +++ b/test/fetch/client-node-max-header-size.js @@ -0,0 +1,29 @@ +'use strict' + +const { execSync } = require('node:child_process') +const { test, skip } = require('tap') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const command = 'node -e "require(\'./undici-fetch.js\').fetch(\'https://httpbin.org/get\')"' + +test("respect Node.js' --max-http-header-size", async (t) => { + t.throws( + // TODO: Drop the `--unhandled-rejections=throw` once we drop Node.js 14 + () => execSync(`${command} --max-http-header-size=1 --unhandled-rejections=throw`), + /UND_ERR_HEADERS_OVERFLOW/, + 'max-http-header-size=1 should throw' + ) + + t.doesNotThrow( + () => execSync(command), + /UND_ERR_HEADERS_OVERFLOW/, + 'default max-http-header-size should not throw' + ) + + t.end() +}) diff --git a/test/fetch/content-length.js b/test/fetch/content-length.js new file mode 100644 index 0000000..9264091 --- /dev/null +++ b/test/fetch/content-length.js @@ -0,0 +1,29 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { Blob } = require('buffer') +const { fetch, FormData } = require('../..') + +// https://github.com/nodejs/undici/issues/1783 +test('Content-Length is set when using a FormData body with fetch', async (t) => { + const server = createServer((req, res) => { + // TODO: check the length's value once the boundary has a fixed length + t.ok('content-length' in req.headers) // request has content-length header + t.ok(!Number.isNaN(Number(req.headers['content-length']))) + res.end() + }).listen(0) + + await once(server, 'listening') + t.teardown(server.close.bind(server)) + + const fd = new FormData() + fd.set('file', new Blob(['hello world 👋'], { type: 'text/plain' }), 'readme.md') + fd.set('string', 'some string value') + + await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + body: fd + }) +}) diff --git a/test/fetch/cookies.js b/test/fetch/cookies.js new file mode 100644 index 0000000..18b001d --- /dev/null +++ b/test/fetch/cookies.js @@ -0,0 +1,69 @@ +'use strict' + +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') +const { fetch, Headers } = require('../..') + +test('Can receive set-cookie headers from a server using fetch - issue #1262', async (t) => { + const server = createServer((req, res) => { + res.setHeader('set-cookie', 'name=value; Domain=example.com') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(response.headers.get('set-cookie'), 'name=value; Domain=example.com') + + const response2 = await fetch(`http://localhost:${server.address().port}`, { + credentials: 'include' + }) + + t.equal(response2.headers.get('set-cookie'), 'name=value; Domain=example.com') + + t.end() +}) + +test('Can send cookies to a server with fetch - issue #1463', async (t) => { + const server = createServer((req, res) => { + t.equal(req.headers.cookie, 'value') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const headersInit = [ + new Headers([['cookie', 'value']]), + { cookie: 'value' }, + [['cookie', 'value']] + ] + + for (const headers of headersInit) { + await fetch(`http://localhost:${server.address().port}`, { headers }) + } + + t.end() +}) + +test('Cookie header is delimited with a semicolon rather than a comma - issue #1905', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.equal(req.headers.cookie, 'FOO=lorem-ipsum-dolor-sit-amet; BAR=the-quick-brown-fox') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: [ + ['cookie', 'FOO=lorem-ipsum-dolor-sit-amet'], + ['cookie', 'BAR=the-quick-brown-fox'] + ] + }) +}) diff --git a/test/fetch/data-uri.js b/test/fetch/data-uri.js new file mode 100644 index 0000000..6191bfe --- /dev/null +++ b/test/fetch/data-uri.js @@ -0,0 +1,214 @@ +'use strict' + +const { test } = require('tap') +const { + URLSerializer, + collectASequenceOfCodePoints, + stringPercentDecode, + parseMIMEType, + collectAnHTTPQuotedString +} = require('../../lib/fetch/dataURL') +const { fetch } = require('../..') + +test('https://url.spec.whatwg.org/#concept-url-serializer', (t) => { + t.test('url scheme gets appended', (t) => { + const url = new URL('https://www.google.com/') + const serialized = URLSerializer(url) + + t.ok(serialized.startsWith(url.protocol)) + t.end() + }) + + t.test('non-null url host with authentication', (t) => { + const url = new URL('https://username:password@google.com') + const serialized = URLSerializer(url) + + t.ok(serialized.includes(`//${url.username}:${url.password}`)) + t.ok(serialized.endsWith('@google.com/')) + t.end() + }) + + t.test('null url host', (t) => { + for (const url of ['web+demo:/.//not-a-host/', 'web+demo:/path/..//not-a-host/']) { + t.equal( + URLSerializer(new URL(url)), + 'web+demo:/.//not-a-host/' + ) + } + + t.end() + }) + + t.test('url with query works', (t) => { + t.equal( + URLSerializer(new URL('https://www.google.com/?fetch=undici')), + 'https://www.google.com/?fetch=undici' + ) + + t.end() + }) + + t.test('exclude fragment', (t) => { + t.equal( + URLSerializer(new URL('https://www.google.com/#frag')), + 'https://www.google.com/#frag' + ) + + t.equal( + URLSerializer(new URL('https://www.google.com/#frag'), true), + 'https://www.google.com/' + ) + + t.end() + }) + + t.end() +}) + +test('https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points', (t) => { + const input = 'text/plain;base64,' + const position = { position: 0 } + const result = collectASequenceOfCodePoints( + (char) => char !== ';', + input, + position + ) + + t.strictSame(result, 'text/plain') + t.strictSame(position.position, input.indexOf(';')) + t.end() +}) + +test('https://url.spec.whatwg.org/#string-percent-decode', (t) => { + t.test('encodes %{2} in range properly', (t) => { + const input = '%FF' + const percentDecoded = stringPercentDecode(input) + + t.same(percentDecoded, new Uint8Array([255])) + t.end() + }) + + t.test('encodes %{2} not in range properly', (t) => { + const input = 'Hello %XD World' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + t.same(percentDecoded, expected) + t.end() + }) + + t.test('normal string works', (t) => { + const input = 'Hello world' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + t.same(percentDecoded, Uint8Array.from(expected)) + t.end() + }) + + t.end() +}) + +test('https://mimesniff.spec.whatwg.org/#parse-a-mime-type', (t) => { + t.same(parseMIMEType('text/plain'), { + type: 'text', + subtype: 'plain', + parameters: new Map(), + essence: 'text/plain' + }) + + t.same(parseMIMEType('text/html;charset="shift_jis"iso-2022-jp'), { + type: 'text', + subtype: 'html', + parameters: new Map([['charset', 'shift_jis']]), + essence: 'text/html' + }) + + t.same(parseMIMEType('application/javascript'), { + type: 'application', + subtype: 'javascript', + parameters: new Map(), + essence: 'application/javascript' + }) + + t.end() +}) + +test('https://fetch.spec.whatwg.org/#collect-an-http-quoted-string', (t) => { + // https://fetch.spec.whatwg.org/#example-http-quoted-string + t.test('first', (t) => { + const position = { position: 0 } + + t.strictSame(collectAnHTTPQuotedString('"\\', { + position: 0 + }), '"\\') + t.strictSame(collectAnHTTPQuotedString('"\\', position, true), '\\') + t.strictSame(position.position, 2) + t.end() + }) + + t.test('second', (t) => { + const position = { position: 0 } + const input = '"Hello" World' + + t.strictSame(collectAnHTTPQuotedString(input, { + position: 0 + }), '"Hello"') + t.strictSame(collectAnHTTPQuotedString(input, position, true), 'Hello') + t.strictSame(position.position, 7) + t.end() + }) + + t.end() +}) + +// https://github.com/nodejs/undici/issues/1574 +test('too long base64 url', async (t) => { + const inputStr = 'a'.repeat(1 << 20) + const base64 = Buffer.from(inputStr).toString('base64') + const dataURIPrefix = 'data:application/octet-stream;base64,' + const dataURL = dataURIPrefix + base64 + try { + const res = await fetch(dataURL) + const buf = await res.arrayBuffer() + const outputStr = Buffer.from(buf).toString('ascii') + t.same(outputStr, inputStr) + } catch (e) { + t.fail(`failed to fetch ${dataURL}`) + } +}) + +test('https://domain.com/#', (t) => { + t.plan(1) + const domain = 'https://domain.com/#a' + const serialized = URLSerializer(new URL(domain)) + t.equal(serialized, domain) +}) + +test('https://domain.com/?', (t) => { + t.plan(1) + const domain = 'https://domain.com/?a=b' + const serialized = URLSerializer(new URL(domain)) + t.equal(serialized, domain) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('hash url', (t) => { + t.plan(1) + const domain = 'https://domain.com/#a#b' + const url = new URL(domain) + const serialized = URLSerializer(url, true) + t.equal(serialized, url.href.substring(0, url.href.length - url.hash.length)) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('data url that includes the hash', async (t) => { + t.plan(1) + const dataURL = 'data:,node#js#' + try { + const res = await fetch(dataURL) + t.equal(await res.text(), 'node') + } catch (error) { + t.fail(`failed to fetch ${dataURL}`) + } +}) diff --git a/test/fetch/encoding.js b/test/fetch/encoding.js new file mode 100644 index 0000000..75d8fc3 --- /dev/null +++ b/test/fetch/encoding.js @@ -0,0 +1,58 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') +const { createBrotliCompress, createGzip, createDeflate } = require('zlib') + +test('content-encoding header is case-iNsENsITIve', async (t) => { + const contentCodings = 'GZiP, bR' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const brotli = createBrotliCompress() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + brotli.pipe(gzip).pipe(res) + + brotli.write(text) + brotli.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(await response.text(), text) + t.equal(response.headers.get('content-encoding'), contentCodings) +}) + +test('response decompression according to content-encoding should be handled in a correct order', async (t) => { + const contentCodings = 'deflate, gzip' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const deflate = createDeflate() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + gzip.pipe(deflate).pipe(res) + + gzip.write(text) + gzip.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(await response.text(), text) +}) diff --git a/test/fetch/fetch-leak.js b/test/fetch/fetch-leak.js new file mode 100644 index 0000000..b8e6b16 --- /dev/null +++ b/test/fetch/fetch-leak.js @@ -0,0 +1,44 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') + +test('do not leak', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + let url + let done = false + server.listen(0, function attack () { + if (done) { + return + } + url ??= new URL(`http://127.0.0.1:${server.address().port}`) + const controller = new AbortController() + fetch(url, { signal: controller.signal }) + .then(res => res.arrayBuffer()) + .catch(() => {}) + .then(attack) + }) + + let prev = Infinity + let count = 0 + const interval = setInterval(() => { + done = true + global.gc() + const next = process.memoryUsage().heapUsed + if (next <= prev) { + t.pass() + } else if (count++ > 20) { + t.fail() + } else { + prev = next + } + }, 1e3) + t.teardown(() => clearInterval(interval)) +}) diff --git a/test/fetch/fetch-timeouts.js b/test/fetch/fetch-timeouts.js new file mode 100644 index 0000000..b659aaa --- /dev/null +++ b/test/fetch/fetch-timeouts.js @@ -0,0 +1,56 @@ +'use strict' + +const { test } = require('tap') + +const { fetch, Agent } = require('../..') +const timers = require('../../lib/timers') +const { createServer } = require('http') +const FakeTimers = require('@sinonjs/fake-timers') + +test('Fetch very long request, timeout overridden so no error', (t) => { + const minutes = 6 + const msToDelay = 1000 * 60 * minutes + + t.setTimeout(undefined) + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, msToDelay) + clock.tick(msToDelay + 1) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`, { + path: '/', + method: 'GET', + dispatcher: new Agent({ + headersTimeout: 0, + connectTimeout: 0, + bodyTimeout: 0 + }) + }) + .then((response) => response.text()) + .then((response) => { + t.equal('hello', response) + t.end() + }) + .catch((err) => { + // This should not happen, a timeout error should not occur + t.error(err) + }) + + clock.tick(msToDelay - 1) + }) +}) diff --git a/test/fetch/file.js b/test/fetch/file.js new file mode 100644 index 0000000..5901541 --- /dev/null +++ b/test/fetch/file.js @@ -0,0 +1,190 @@ +'use strict' + +const { Blob } = require('buffer') +const { test } = require('tap') +const { File, FileLike } = require('../../lib/fetch/file') + +test('args validation', (t) => { + t.plan(14) + + t.throws(() => { + File.prototype.name.toString() + }, TypeError) + t.throws(() => { + File.prototype.lastModified.toString() + }, TypeError) + t.doesNotThrow(() => { + File.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.throws(() => { + FileLike.prototype.stream.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.arrayBuffer.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.slice.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.text.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.size.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.type.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.name.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.lastModified.toString() + }, TypeError) + t.doesNotThrow(() => { + FileLike.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.equal(File.prototype[Symbol.toStringTag], 'File') + t.equal(FileLike.prototype[Symbol.toStringTag], 'File') +}) + +test('return value of File.lastModified', (t) => { + t.plan(2) + + const f = new File(['asd1'], 'filename123') + const lastModified = f.lastModified + t.ok(typeof lastModified === typeof Date.now()) + t.ok(lastModified >= 0 && lastModified <= Date.now()) +}) + +test('Symbol.toStringTag', (t) => { + t.plan(2) + t.equal(new File([], '')[Symbol.toStringTag], 'File') + t.equal(new FileLike()[Symbol.toStringTag], 'File') +}) + +test('arguments', (t) => { + t.throws(() => { + new File() // eslint-disable-line no-new + }, TypeError) + + t.throws(() => { + new File([]) // eslint-disable-line no-new + }, TypeError) + + t.end() +}) + +test('lastModified', (t) => { + const file = new File([], '') + const lastModified = Date.now() - 69_000 + + t.notOk(file === 0) + + const file1 = new File([], '', { lastModified }) + t.equal(file1.lastModified, lastModified) + + t.equal(new File([], '', { lastModified: 0 }).lastModified, 0) + + t.equal( + new File([], '', { + lastModified: true + }).lastModified, + 1 + ) + + t.end() +}) + +test('File.prototype.text', async (t) => { + t.test('With Blob', async (t) => { + const blob1 = new Blob(['hello']) + const blob2 = new Blob([' ']) + const blob3 = new Blob(['world']) + + const file = new File([blob1, blob2, blob3], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + /* eslint-disable camelcase */ + t.test('With TypedArray', async (t) => { + const uint8_1 = new Uint8Array(Buffer.from('hello')) + const uint8_2 = new Uint8Array(Buffer.from(' ')) + const uint8_3 = new Uint8Array(Buffer.from('world')) + + const file = new File([uint8_1, uint8_2, uint8_3], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('With TypedArray range', async (t) => { + const uint8_1 = new Uint8Array(Buffer.from('hello world')) + const uint8_2 = new Uint8Array(uint8_1.buffer, 1, 4) + + const file = new File([uint8_2], 'hello_world.txt') + + t.equal(await file.text(), 'ello') + t.end() + }) + /* eslint-enable camelcase */ + + t.test('With ArrayBuffer', async (t) => { + const uint8 = new Uint8Array([65, 66, 67]) + const ab = uint8.buffer + + const file = new File([ab], 'file.txt') + + t.equal(await file.text(), 'ABC') + t.end() + }) + + t.test('With string', async (t) => { + const string = 'hello world' + const file = new File([string], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('With Buffer', async (t) => { + const buffer = Buffer.from('hello world') + + const file = new File([buffer], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('Mixed', async (t) => { + const blob = new Blob(['Hello, ']) + const uint8 = new Uint8Array(Buffer.from('world! This')) + const string = ' is a test! Hope it passes!' + + const file = new File([blob, uint8, string], 'mixed-messages.txt') + + t.equal( + await file.text(), + 'Hello, world! This is a test! Hope it passes!' + ) + t.end() + }) + + t.end() +}) + +test('endings=native', async (t) => { + const file = new File(['Hello\nWorld'], 'text.txt', { endings: 'native' }) + const text = await file.text() + + if (process.platform === 'win32') { + t.equal(text, 'Hello\r\nWorld', 'on windows, LF is replace with CRLF') + } else { + t.equal(text, 'Hello\nWorld', `on ${process.platform} LF stays LF`) + } + + t.end() +}) diff --git a/test/fetch/formdata.js b/test/fetch/formdata.js new file mode 100644 index 0000000..fed95bf --- /dev/null +++ b/test/fetch/formdata.js @@ -0,0 +1,401 @@ +'use strict' + +const { test } = require('tap') +const { FormData, File, Response } = require('../../') +const { Blob: ThirdPartyBlob } = require('formdata-node') +const { Blob } = require('buffer') +const { isFormDataLike } = require('../../lib/core/util') +const ThirdPartyFormDataInvalid = require('form-data') + +test('arg validation', (t) => { + const form = new FormData() + + // constructor + t.throws(() => { + // eslint-disable-next-line + new FormData('asd') + }, TypeError) + + // append + t.throws(() => { + FormData.prototype.append.call(null) + }, TypeError) + t.throws(() => { + form.append() + }, TypeError) + t.throws(() => { + form.append('k', 'not usv', '') + }, TypeError) + + // delete + t.throws(() => { + FormData.prototype.delete.call(null) + }, TypeError) + t.throws(() => { + form.delete() + }, TypeError) + + // get + t.throws(() => { + FormData.prototype.get.call(null) + }, TypeError) + t.throws(() => { + form.get() + }, TypeError) + + // getAll + t.throws(() => { + FormData.prototype.getAll.call(null) + }, TypeError) + t.throws(() => { + form.getAll() + }, TypeError) + + // has + t.throws(() => { + FormData.prototype.has.call(null) + }, TypeError) + t.throws(() => { + form.has() + }, TypeError) + + // set + t.throws(() => { + FormData.prototype.set.call(null) + }, TypeError) + t.throws(() => { + form.set('k') + }, TypeError) + t.throws(() => { + form.set('k', 'not usv', '') + }, TypeError) + + // iterator + t.throws(() => { + Reflect.apply(FormData.prototype[Symbol.iterator], null) + }, TypeError) + + // toStringTag + t.doesNotThrow(() => { + FormData.prototype[Symbol.toStringTag].charAt(0) + }) + + t.end() +}) + +test('append file', (t) => { + const form = new FormData() + form.set('asd', new File([], 'asd1', { type: 'text/plain' }), 'asd2') + form.append('asd2', new File([], 'asd1'), 'asd2') + + t.equal(form.has('asd'), true) + t.equal(form.has('asd2'), true) + t.equal(form.get('asd').name, 'asd2') + t.equal(form.get('asd2').name, 'asd2') + t.equal(form.get('asd').type, 'text/plain') + form.delete('asd') + t.equal(form.get('asd'), null) + t.equal(form.has('asd2'), true) + t.equal(form.has('asd'), false) + + t.end() +}) + +test('append blob', async (t) => { + const form = new FormData() + form.set('asd', new Blob(['asd1'], { type: 'text/plain' })) + + t.equal(form.has('asd'), true) + t.equal(form.get('asd').type, 'text/plain') + t.equal(await form.get('asd').text(), 'asd1') + form.delete('asd') + t.equal(form.get('asd'), null) + + t.end() +}) + +test('append third-party blob', async (t) => { + const form = new FormData() + form.set('asd', new ThirdPartyBlob(['asd1'], { type: 'text/plain' })) + + t.equal(form.has('asd'), true) + t.equal(form.get('asd').type, 'text/plain') + t.equal(await form.get('asd').text(), 'asd1') + form.delete('asd') + t.equal(form.get('asd'), null) + + t.end() +}) + +test('append string', (t) => { + const form = new FormData() + form.set('k1', 'v1') + form.set('k2', 'v2') + t.same([...form], [['k1', 'v1'], ['k2', 'v2']]) + t.equal(form.has('k1'), true) + t.equal(form.get('k1'), 'v1') + form.append('k1', 'v1+') + t.same(form.getAll('k1'), ['v1', 'v1+']) + form.set('k2', 'v1++') + t.equal(form.get('k2'), 'v1++') + form.delete('asd') + t.equal(form.get('asd'), null) + t.end() +}) + +test('formData.entries', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 entries', (t) => { + t.plan(1) + + const entries = [...form.entries()] + t.same(entries, []) + }) + + t.test('with 1+ entries', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const entries = [...form.entries()] + const entries2 = [...form.entries()] + t.same(entries, [['k1', 'v1'], ['k2', 'v2']]) + t.same(entries, entries2) + }) +}) + +test('formData.keys', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 keys', (t) => { + t.plan(1) + + const keys = [...form.entries()] + t.same(keys, []) + }) + + t.test('with 1+ keys', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const keys = [...form.keys()] + const keys2 = [...form.keys()] + t.same(keys, ['k1', 'k2']) + t.same(keys, keys2) + }) +}) + +test('formData.values', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 values', (t) => { + t.plan(1) + + const values = [...form.values()] + t.same(values, []) + }) + + t.test('with 1+ values', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const values = [...form.values()] + const values2 = [...form.values()] + t.same(values, ['v1', 'v2']) + t.same(values, values2) + }) +}) + +test('formData forEach', (t) => { + t.test('invalid arguments', (t) => { + t.throws(() => { + FormData.prototype.forEach.call({}) + }, TypeError('Illegal invocation')) + + t.throws(() => { + const fd = new FormData() + + fd.forEach({}) + }, TypeError) + + t.end() + }) + + t.test('with a callback', (t) => { + const fd = new FormData() + + fd.set('a', 'b') + fd.set('c', 'd') + + let i = 0 + fd.forEach((value, key, self) => { + if (i++ === 0) { + t.equal(value, 'b') + t.equal(key, 'a') + } else { + t.equal(value, 'd') + t.equal(key, 'c') + } + + t.equal(fd, self) + }) + + t.end() + }) + + t.test('with a thisArg', (t) => { + const fd = new FormData() + fd.set('b', 'a') + + fd.forEach(function (value, key, self) { + t.equal(this, globalThis) + t.equal(fd, self) + t.equal(key, 'b') + t.equal(value, 'a') + }) + + const thisArg = Symbol('thisArg') + fd.forEach(function () { + t.equal(this, thisArg) + }, thisArg) + + t.end() + }) + + t.end() +}) + +test('formData toStringTag', (t) => { + const form = new FormData() + t.equal(form[Symbol.toStringTag], 'FormData') + t.equal(FormData.prototype[Symbol.toStringTag], 'FormData') + t.end() +}) + +test('formData.constructor.name', (t) => { + const form = new FormData() + t.equal(form.constructor.name, 'FormData') + t.end() +}) + +test('formData should be an instance of FormData', (t) => { + t.plan(3) + + t.test('Invalid class FormData', (t) => { + class FormData { + constructor () { + this.data = [] + } + + append (key, value) { + this.data.push([key, value]) + } + + get (key) { + return this.data.find(([k]) => k === key) + } + } + + const form = new FormData() + t.equal(isFormDataLike(form), false) + t.end() + }) + + t.test('Invalid function FormData', (t) => { + function FormData () { + const data = [] + return { + append (key, value) { + data.push([key, value]) + }, + get (key) { + return data.find(([k]) => k === key) + } + } + } + + const form = new FormData() + t.equal(isFormDataLike(form), false) + t.end() + }) + + test('Invalid third-party FormData', (t) => { + const form = new ThirdPartyFormDataInvalid() + t.equal(isFormDataLike(form), false) + t.end() + }) + + t.test('Valid FormData', (t) => { + const form = new FormData() + t.equal(isFormDataLike(form), true) + t.end() + }) +}) + +test('FormData should be compatible with third-party libraries', (t) => { + t.plan(1) + + class FormData { + constructor () { + this.data = [] + } + + get [Symbol.toStringTag] () { + return 'FormData' + } + + append () {} + delete () {} + get () {} + getAll () {} + has () {} + set () {} + entries () {} + keys () {} + values () {} + forEach () {} + } + + const form = new FormData() + t.equal(isFormDataLike(form), true) +}) + +test('arguments', (t) => { + t.equal(FormData.constructor.length, 1) + t.equal(FormData.prototype.append.length, 2) + t.equal(FormData.prototype.delete.length, 1) + t.equal(FormData.prototype.get.length, 1) + t.equal(FormData.prototype.getAll.length, 1) + t.equal(FormData.prototype.has.length, 1) + t.equal(FormData.prototype.set.length, 2) + + t.end() +}) + +// https://github.com/nodejs/undici/pull/1814 +test('FormData returned from bodyMixin.formData is not a clone', async (t) => { + const fd = new FormData() + fd.set('foo', 'bar') + + const res = new Response(fd) + fd.set('foo', 'foo') + + const fd2 = await res.formData() + + t.equal(fd2.get('foo'), 'bar') + t.equal(fd.get('foo'), 'foo') + + fd2.set('foo', 'baz') + + t.equal(fd2.get('foo'), 'baz') + t.equal(fd.get('foo'), 'foo') +}) diff --git a/test/fetch/general.js b/test/fetch/general.js new file mode 100644 index 0000000..0469875 --- /dev/null +++ b/test/fetch/general.js @@ -0,0 +1,30 @@ +'use strict' + +const { test } = require('tap') +const { + File, + FormData, + Headers, + Request, + Response +} = require('../../index') + +test('Symbol.toStringTag descriptor', (t) => { + for (const cls of [ + File, + FormData, + Headers, + Request, + Response + ]) { + const desc = Object.getOwnPropertyDescriptor(cls.prototype, Symbol.toStringTag) + t.same(desc, { + value: cls.name, + writable: false, + enumerable: false, + configurable: true + }) + } + + t.end() +}) diff --git a/test/fetch/headers.js b/test/fetch/headers.js new file mode 100644 index 0000000..4846110 --- /dev/null +++ b/test/fetch/headers.js @@ -0,0 +1,743 @@ +'use strict' + +const tap = require('tap') +const { Headers, fill } = require('../../lib/fetch/headers') +const { kGuard } = require('../../lib/fetch/symbols') +const { once } = require('events') +const { fetch } = require('../..') +const { createServer } = require('http') + +tap.test('Headers initialization', t => { + t.plan(8) + + t.test('allows undefined', t => { + t.plan(1) + + t.doesNotThrow(() => new Headers()) + }) + + t.test('with array of header entries', t => { + t.plan(3) + + t.test('fails on invalid array-based init', t => { + t.plan(3) + t.throws( + () => new Headers([['undici', 'fetch'], ['fetch']]), + TypeError('Headers constructor: expected name/value pair to be length 2, found 1.') + ) + t.throws(() => new Headers(['undici', 'fetch', 'fetch']), TypeError) + t.throws( + () => new Headers([0, 1, 2]), + TypeError('Sequence: Value of type Number is not an Object.') + ) + }) + + t.test('allows even length init', t => { + t.plan(1) + const init = [['undici', 'fetch'], ['fetch', 'undici']] + t.doesNotThrow(() => new Headers(init)) + }) + + t.test('fails for event flattened init', t => { + t.plan(1) + const init = ['undici', 'fetch', 'fetch', 'undici'] + t.throws( + () => new Headers(init), + TypeError('Sequence: Value of type String is not an Object.') + ) + }) + }) + + t.test('with object of header entries', t => { + t.plan(1) + const init = { + undici: 'fetch', + fetch: 'undici' + } + t.doesNotThrow(() => new Headers(init)) + }) + + t.test('fails silently if a boxed primitive object is passed', t => { + t.plan(3) + /* eslint-disable no-new-wrappers */ + t.doesNotThrow(() => new Headers(new Number())) + t.doesNotThrow(() => new Headers(new Boolean())) + t.doesNotThrow(() => new Headers(new String())) + /* eslint-enable no-new-wrappers */ + }) + + t.test('fails if primitive is passed', t => { + t.plan(2) + const expectedTypeError = TypeError + t.throws(() => new Headers(1), expectedTypeError) + t.throws(() => new Headers('1'), expectedTypeError) + }) + + t.test('allows some weird stuff (because of webidl)', t => { + t.doesNotThrow(() => { + new Headers(function () {}) // eslint-disable-line no-new + }) + + t.doesNotThrow(() => { + new Headers(Function) // eslint-disable-line no-new + }) + + t.end() + }) + + t.test('allows a myriad of header values to be passed', t => { + t.plan(4) + + // Headers constructor uses Headers.append + + t.doesNotThrow(() => new Headers([ + ['a', ['b', 'c']], + ['d', ['e', 'f']] + ]), 'allows any array values') + t.doesNotThrow(() => new Headers([ + ['key', null] + ]), 'allows null values') + t.throws(() => new Headers([ + ['key'] + ]), 'throws when 2 arguments are not passed') + t.throws(() => new Headers([ + ['key', 'value', 'value2'] + ]), 'throws when too many arguments are passed') + }) + + t.test('accepts headers as objects with array values', t => { + t.plan(1) + const headers = new Headers({ + c: '5', + b: ['3', '4'], + a: ['1', '2'] + }) + + t.same([...headers.entries()], [ + ['a', '1,2'], + ['b', '3,4'], + ['c', '5'] + ]) + }) +}) + +tap.test('Headers append', t => { + t.plan(3) + + t.test('adds valid header entry to instance', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + t.doesNotThrow(() => headers.append(name, value)) + t.equal(headers.get(name), value) + }) + + t.test('adds valid header to existing entry', t => { + t.plan(4) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + const value3 = 'fetch3' + headers.append(name, value1) + t.equal(headers.get(name), value1) + t.doesNotThrow(() => headers.append(name, value2)) + t.doesNotThrow(() => headers.append(name, value3)) + t.equal(headers.get(name), [value1, value2, value3].join(', ')) + }) + + t.test('throws on invalid entry', t => { + t.plan(3) + const headers = new Headers() + + t.throws(() => headers.append(), 'throws on missing name and value') + t.throws(() => headers.append('undici'), 'throws on missing value') + t.throws(() => headers.append('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) +}) + +tap.test('Headers delete', t => { + t.plan(4) + + t.test('deletes valid header entry from instance', t => { + t.plan(3) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + t.equal(headers.get(name), value) + t.doesNotThrow(() => headers.delete(name)) + t.equal(headers.get(name), null) + }) + + t.test('does not mutate internal list when no match is found', t => { + t.plan(3) + + const headers = new Headers() + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + t.equal(headers.get(name), value) + t.doesNotThrow(() => headers.delete('not-undici')) + t.equal(headers.get(name), value) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.delete(), 'throws on missing namee') + t.throws(() => headers.delete('invalid @ header ? name'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2429 + t.test('`Headers#delete` returns undefined', t => { + t.plan(2) + const headers = new Headers({ test: 'test' }) + + t.same(headers.delete('test'), undefined) + t.same(headers.delete('test2'), undefined) + }) +}) + +tap.test('Headers get', t => { + t.plan(3) + + t.test('returns null if not found in instance', t => { + t.plan(1) + const headers = new Headers() + headers.append('undici', 'fetch') + + t.equal(headers.get('not-undici'), null) + }) + + t.test('returns header values from valid header name', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici'; const value1 = 'fetch1'; const value2 = 'fetch2' + headers.append(name, value1) + t.equal(headers.get(name), value1) + headers.append(name, value2) + t.equal(headers.get(name), [value1, value2].join(', ')) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.get(), 'throws on missing name') + t.throws(() => headers.get('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +tap.test('Headers has', t => { + t.plan(2) + + t.test('returns boolean existence for a header name', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + headers.append('not-undici', 'fetch') + t.equal(headers.has(name), false) + headers.append(name, 'fetch') + t.equal(headers.has(name), true) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.has(), 'throws on missing name') + t.throws(() => headers.has('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +tap.test('Headers set', t => { + t.plan(5) + + t.test('sets valid header entry to instance', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append('not-undici', 'fetch') + t.doesNotThrow(() => headers.set(name, value)) + t.equal(headers.get(name), value) + }) + + t.test('overwrites existing entry', t => { + t.plan(4) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + t.doesNotThrow(() => headers.set(name, value1)) + t.equal(headers.get(name), value1) + t.doesNotThrow(() => headers.set(name, value2)) + t.equal(headers.get(name), value2) + }) + + t.test('allows setting a myriad of values', t => { + t.plan(4) + const headers = new Headers() + + t.doesNotThrow(() => headers.set('a', ['b', 'c']), 'sets array values properly') + t.doesNotThrow(() => headers.set('b', null), 'allows setting null values') + t.throws(() => headers.set('c'), 'throws when 2 arguments are not passed') + t.doesNotThrow(() => headers.set('c', 'd', 'e'), 'ignores extra arguments') + }) + + t.test('throws on invalid entry', t => { + t.plan(3) + const headers = new Headers() + + t.throws(() => headers.set(), 'throws on missing name and value') + t.throws(() => headers.set('undici'), 'throws on missing value') + t.throws(() => headers.set('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2431 + t.test('`Headers#set` returns undefined', t => { + t.plan(2) + const headers = new Headers() + + t.same(headers.set('a', 'b'), undefined) + + t.notOk(headers.set('c', 'd') instanceof Map) + }) +}) + +tap.test('Headers forEach', t => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + t.test('standard', t => { + t.equal(typeof headers.forEach, 'function') + + headers.forEach((value, key, headerInstance) => { + t.ok(value === 'b' || value === 'd') + t.ok(key === 'a' || key === 'c') + t.equal(headers, headerInstance) + }) + + t.end() + }) + + t.test('when no thisArg is set, it is globalThis', (t) => { + headers.forEach(function () { + t.equal(this, globalThis) + }) + + t.end() + }) + + t.test('with thisArg', t => { + const thisArg = { a: Math.random() } + headers.forEach(function () { + t.equal(this, thisArg) + }, thisArg) + + t.end() + }) + + t.end() +}) + +tap.test('Headers as Iterable', t => { + t.plan(7) + + t.test('should freeze values while iterating', t => { + t.plan(1) + const init = [ + ['foo', '123'], + ['bar', '456'] + ] + const expected = [ + ['foo', '123'], + ['x-x-bar', '456'] + ] + const headers = new Headers(init) + for (const [key, val] of headers) { + headers.delete(key) + headers.set(`x-${key}`, val) + } + t.strictSame([...headers], expected) + }) + + t.test('returns combined and sorted entries using .forEach()', t => { + t.plan(8) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + const that = {} + let i = 0 + headers.forEach(function (value, key, _headers) { + t.strictSame(expected[i++], [key, value]) + t.equal(this, that) + }, that) + }) + + t.test('returns combined and sorted entries using .entries()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + let i = 0 + for (const header of headers.entries()) { + t.strictSame(header, expected[i++]) + } + }) + + t.test('returns combined and sorted keys using .keys()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['a', 'abc', 'b', 'c'] + const headers = new Headers(init) + let i = 0 + for (const key of headers.keys()) { + t.strictSame(key, expected[i++]) + } + }) + + t.test('returns combined and sorted values using .values()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['1', '4', '2, 5', '3'] + const headers = new Headers(init) + let i = 0 + for (const value of headers.values()) { + t.strictSame(value, expected[i++]) + } + }) + + t.test('returns combined and sorted entries using for...of loop', t => { + t.plan(5) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'], + ['d', ['6', '7']] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'], + ['d', '6,7'] + ] + let i = 0 + for (const header of new Headers(init)) { + t.strictSame(header, expected[i++]) + } + }) + + t.test('validate append ordering', t => { + t.plan(1) + const headers = new Headers([['b', '2'], ['c', '3'], ['e', '5']]) + headers.append('d', '4') + headers.append('a', '1') + headers.append('f', '6') + headers.append('c', '7') + headers.append('abc', '8') + + const expected = [...new Map([ + ['a', '1'], + ['abc', '8'], + ['b', '2'], + ['c', '3, 7'], + ['d', '4'], + ['e', '5'], + ['f', '6'] + ])] + + t.same([...headers], expected) + }) +}) + +tap.test('arg validation', (t) => { + // fill + t.throws(() => { + fill({}, 0) + }, TypeError) + + const headers = new Headers() + + // constructor + t.throws(() => { + // eslint-disable-next-line + new Headers(0) + }, TypeError) + + // get [Symbol.toStringTag] + t.doesNotThrow(() => { + Object.prototype.toString.call(Headers.prototype) + }) + + // toString + t.doesNotThrow(() => { + Headers.prototype.toString.call(null) + }) + + // append + t.throws(() => { + Headers.prototype.append.call(null) + }, TypeError) + t.throws(() => { + headers.append() + }, TypeError) + + // delete + t.throws(() => { + Headers.prototype.delete.call(null) + }, TypeError) + t.throws(() => { + headers.delete() + }, TypeError) + + // get + t.throws(() => { + Headers.prototype.get.call(null) + }, TypeError) + t.throws(() => { + headers.get() + }, TypeError) + + // has + t.throws(() => { + Headers.prototype.has.call(null) + }, TypeError) + t.throws(() => { + headers.has() + }, TypeError) + + // set + t.throws(() => { + Headers.prototype.set.call(null) + }, TypeError) + t.throws(() => { + headers.set() + }, TypeError) + + // forEach + t.throws(() => { + Headers.prototype.forEach.call(null) + }, TypeError) + t.throws(() => { + headers.forEach() + }, TypeError) + t.throws(() => { + headers.forEach(1) + }, TypeError) + + // inspect + t.throws(() => { + Headers.prototype[Symbol.for('nodejs.util.inspect.custom')].call(null) + }, TypeError) + + t.end() +}) + +tap.test('function signature verification', (t) => { + t.test('function length', (t) => { + t.equal(Headers.prototype.append.length, 2) + t.equal(Headers.prototype.constructor.length, 0) + t.equal(Headers.prototype.delete.length, 1) + t.equal(Headers.prototype.entries.length, 0) + t.equal(Headers.prototype.forEach.length, 1) + t.equal(Headers.prototype.get.length, 1) + t.equal(Headers.prototype.has.length, 1) + t.equal(Headers.prototype.keys.length, 0) + t.equal(Headers.prototype.set.length, 2) + t.equal(Headers.prototype.values.length, 0) + t.equal(Headers.prototype[Symbol.iterator].length, 0) + t.equal(Headers.prototype.toString.length, 0) + + t.end() + }) + + t.test('function equality', (t) => { + t.equal(Headers.prototype.entries, Headers.prototype[Symbol.iterator]) + t.equal(Headers.prototype.toString, Object.prototype.toString) + + t.end() + }) + + t.test('toString and Symbol.toStringTag', (t) => { + t.equal(Object.prototype.toString.call(Headers.prototype), '[object Headers]') + t.equal(Headers.prototype[Symbol.toStringTag], 'Headers') + t.equal(Headers.prototype.toString.call(null), '[object Null]') + + t.end() + }) + + t.end() +}) + +tap.test('various init paths of Headers', (t) => { + const h1 = new Headers() + const h2 = new Headers({}) + const h3 = new Headers(undefined) + t.equal([...h1.entries()].length, 0) + t.equal([...h2.entries()].length, 0) + t.equal([...h3.entries()].length, 0) + + t.end() +}) + +tap.test('immutable guard', (t) => { + const headers = new Headers() + headers.set('key', 'val') + headers[kGuard] = 'immutable' + + t.throws(() => { + headers.set('asd', 'asd') + }) + t.throws(() => { + headers.append('asd', 'asd') + }) + t.throws(() => { + headers.delete('asd') + }) + t.equal(headers.get('key'), 'val') + t.equal(headers.has('key'), true) + + t.end() +}) + +tap.test('request-no-cors guard', (t) => { + const headers = new Headers() + headers[kGuard] = 'request-no-cors' + t.doesNotThrow(() => { headers.set('key', 'val') }) + t.doesNotThrow(() => { headers.append('key', 'val') }) + t.doesNotThrow(() => { headers.delete('key') }) + t.end() +}) + +tap.test('invalid headers', (t) => { + t.doesNotThrow(() => new Headers({ "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~": 'test' })) + + const chars = '"(),/:;<=>?@[\\]{}'.split('') + + for (const char of chars) { + t.throws(() => new Headers({ [char]: 'test' }), TypeError, `The string "${char}" should throw an error.`) + } + + for (const byte of ['\r', '\n', '\t', ' ', String.fromCharCode(128), '']) { + t.throws(() => { + new Headers().set(byte, 'test') + }, TypeError, 'invalid header name') + } + + for (const byte of [ + '\0', + '\r', + '\n' + ]) { + t.throws(() => { + new Headers().set('a', `a${byte}b`) + }, TypeError, 'not allowed at all in header value') + } + + t.doesNotThrow(() => { + new Headers().set('a', '\r') + }) + + t.doesNotThrow(() => { + new Headers().set('a', '\n') + }) + + t.throws(() => { + new Headers().set('a', Symbol('symbol')) + }, TypeError, 'symbols should throw') + + t.end() +}) + +tap.test('headers that might cause a ReDoS', (t) => { + t.doesNotThrow(() => { + // This test will time out if the ReDoS attack is successful. + const headers = new Headers() + const attack = 'a' + '\t'.repeat(500_000) + '\ta' + headers.append('fhqwhgads', attack) + }) + + t.end() +}) + +tap.test('Headers.prototype.getSetCookie', (t) => { + t.test('Mutating the returned list does not affect the set-cookie list', (t) => { + const h = new Headers([ + ['set-cookie', 'a=b'], + ['set-cookie', 'c=d'] + ]) + + const old = h.getSetCookie() + h.getSetCookie().push('oh=no') + const now = h.getSetCookie() + + t.same(old, now) + t.end() + }) + + // https://github.com/nodejs/undici/issues/1935 + t.test('When Headers are cloned, so are the cookies', async (t) => { + const server = createServer((req, res) => { + res.setHeader('Set-Cookie', 'test=onetwo') + res.end('Hello World!') + }).listen(0) + + await once(server, 'listening') + t.teardown(server.close.bind(server)) + + const res = await fetch(`http://localhost:${server.address().port}`) + const entries = Object.fromEntries(res.headers.entries()) + + t.same(res.headers.getSetCookie(), ['test=onetwo']) + t.ok('set-cookie' in entries) + }) + + t.end() +}) diff --git a/test/fetch/http2.js b/test/fetch/http2.js new file mode 100644 index 0000000..9f6997f --- /dev/null +++ b/test/fetch/http2.js @@ -0,0 +1,415 @@ +'use strict' + +const { createSecureServer } = require('node:http2') +const { createReadStream, readFileSync } = require('node:fs') +const { once } = require('node:events') +const { Blob } = require('node:buffer') +const { Readable } = require('node:stream') + +const { test, plan } = require('tap') +const pem = require('https-pem') + +const { Client, fetch, Headers } = require('../..') + +const nodeVersion = Number(process.version.split('v')[1].split('.')[0]) + +plan(7) + +test('[Fetch] Issue#2311', async t => { + const expectedBody = 'hello from client!' + + const server = createSecureServer(pem, async (req, res) => { + let body = '' + + req.setEncoding('utf8') + + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': req.headers['x-my-header'] + }) + + for await (const chunk of req) { + body += chunk + } + + res.end(body) + }) + + t.plan(1) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(responseBody, expectedBody) +}) + +test('[Fetch] Simple GET with h2', async t => { + const server = createSecureServer(pem) + const expectedRequestBody = 'hello h2!' + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + 'x-method': headers[':method'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + t.plan(5) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(responseBody, expectedRequestBody) + t.equal(response.headers.get('x-method'), 'GET') + t.equal(response.headers.get('x-custom-h2'), 'foo') + // https://github.com/nodejs/undici/issues/2415 + t.throws(() => { + response.headers.get(':status') + }, TypeError) + + // See https://fetch.spec.whatwg.org/#concept-response-status-message + t.equal(response.statusText, '') +}) + +test('[Fetch] Should handle h2 request with body (string or buffer)', async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const expectedRequestBody = 'hello h2!' + const requestBody = [] + + server.on('stream', async (stream, headers) => { + stream.on('data', chunk => requestBody.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + t.plan(2) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(Buffer.concat(requestBody).toString('utf-8'), expectedBody) + t.equal(responseBody, expectedRequestBody) +}) + +// Skipping for now, there is something odd in the way the body is handled +test( + '[Fetch] Should handle h2 request with body (stream)', + { skip: nodeVersion === 16 }, + async t => { + const server = createSecureServer(pem) + const expectedBody = readFileSync(__filename, 'utf-8') + const stream = createReadStream(__filename) + const requestChunks = [] + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'PUT') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + for await (const chunk of stream) { + requestChunks.push(chunk) + } + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: Readable.toWeb(stream), + duplex: 'half' + } + ) + + const responseBody = await response.text() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.equal(responseBody, 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) +test('Should handle h2 request with body (Blob)', { skip: !Blob }, async t => { + const server = createSecureServer(pem) + const expectedBody = 'asd' + const requestChunks = [] + const body = new Blob(['asd'], { + type: 'text/plain' + }) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.arrayBuffer() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.same(new TextDecoder().decode(responseBody).toString(), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test( + 'Should handle h2 request with body (Blob:ArrayBuffer)', + { skip: !Blob }, + async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello' + const requestChunks = [] + const expectedResponseBody = { hello: 'h2' } + const buf = Buffer.from(expectedBody) + const body = new ArrayBuffer(buf.byteLength) + + buf.copy(new Uint8Array(body)) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'PUT') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'application/json', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(JSON.stringify(expectedResponseBody)) + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.json() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'application/json') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.same(responseBody, expectedResponseBody) + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) + +test('Issue#2415', async (t) => { + t.plan(1) + const server = createSecureServer(pem) + + server.on('stream', async (stream, headers) => { + stream.respond({ + ':status': 200 + }) + stream.end('test') + }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client + } + ) + + await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.doesNotThrow(() => new Headers(response.headers)) +}) diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js new file mode 100644 index 0000000..90af891 --- /dev/null +++ b/test/fetch/integrity.js @@ -0,0 +1,347 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { createHash, getHashes } = require('crypto') +const { gzipSync } = require('zlib') +/* +======= +const { test, after } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { createHash, getHashes } = require('node:crypto') +const { gzipSync } = require('node:zlib') +>>>>>>> d542b8cd (Merge pull request from GHSA-9qxr-qj54-h672) +*/ +const { fetch, setGlobalDispatcher, Agent } = require('../..') +const { once } = require('events') + +const supportedHashes = getHashes() + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('request with correct integrity checksum', (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictSame(body, await response.text()) + t.end() + }) +}) + +test('request with wrong integrity checksum', (t) => { + const body = 'Hello world!' + const hash = 'c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51b' + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }).then(response => { + t.pass('request did not fail') + }).catch((err) => { + t.equal(err.cause.message, 'integrity mismatch') + }).finally(() => { + t.end() + }) + }) +}) + +test('request with integrity checksum on encoded body', (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(body)) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictSame(body, await response.text()) + t.end() + }) +}) + +test('request with a totally incorrect integrity', async (t) => { + const server = createServer((req, res) => { + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: 'what-integrityisthis' + })) +}) + +test('request with mixed in/valid integrities', async (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `invalid-integrity sha256-${hash}` + })) +}) + +test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha384').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // request should succeed + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${hash}` + })) + + // request should fail + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha384-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) + +test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // request should succeed + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${hash}` + })) + + // request should fail + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) + +test('request with correct integrity checksum (base64url)', (t) => { + t.plan(1) + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.equal(body, await response.text()) + }) +}) + +test('request with incorrect integrity checksum (base64url)', (t) => { + t.plan(1) + + const body = 'Hello world!' + const hash = createHash('sha256').update('invalid').digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + })) + }) +}) + +test('request with incorrect integrity checksum (only dash)', (t) => { + t.plan(1) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256--' + })) + }) +}) + +test('request with incorrect integrity checksum (non-ascii character)', (t) => { + t.plan(1) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256-ä' + })) + }) +}) + +test('request with incorrect stronger integrity checksum (non-ascii character)', (t) => { + t.plan(2) + + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = 'ä' + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + })) + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + })) + }) +}) + +test('request with correct integrity checksum (base64). mixed', (t) => { + t.plan(6) + + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = createHash('sha384').update(body).digest('base64') + const sha512 = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.equal(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.equal(body, await response.text()) + }) +}) + +test('request with correct integrity checksum (base64url). mixed', (t) => { + t.plan(6) + + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64url') + const sha384 = createHash('sha384').update(body).digest('base64url') + const sha512 = createHash('sha512').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.equal(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.equal(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.equal(body, await response.text()) + }) +}) diff --git a/test/fetch/issue-1447.js b/test/fetch/issue-1447.js new file mode 100644 index 0000000..503b344 --- /dev/null +++ b/test/fetch/issue-1447.js @@ -0,0 +1,46 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const undici = require('../..') +const { fetch: theoreticalGlobalFetch } = require('../../undici-fetch') + +test('Mocking works with both fetches', async (t) => { + const mockAgent = new undici.MockAgent() + const body = JSON.stringify({ foo: 'bar' }) + + mockAgent.disableNetConnect() + undici.setGlobalDispatcher(mockAgent) + const pool = mockAgent.get('https://example.com') + + pool.intercept({ + path: '/path', + method: 'POST', + body (bodyString) { + t.equal(bodyString, body) + return true + } + }).reply(200, { ok: 1 }).times(2) + + const url = new URL('https://example.com/path').href + + // undici fetch from node_modules + await undici.fetch(url, { + method: 'POST', + body + }) + + // the global fetch bundled with esbuild + await theoreticalGlobalFetch(url, { + method: 'POST', + body + }) + + t.end() +}) diff --git a/test/fetch/issue-2009.js b/test/fetch/issue-2009.js new file mode 100644 index 0000000..0b7b3e9 --- /dev/null +++ b/test/fetch/issue-2009.js @@ -0,0 +1,28 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') + +test('issue 2009', async (t) => { + const server = createServer((req, res) => { + res.setHeader('a', 'b') + res.flushHeaders() + + res.socket.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + for (let i = 0; i < 10; i++) { + await t.resolves( + fetch(`http://localhost:${server.address().port}`).then( + async (resp) => { + await resp.body.cancel('Some message') + } + ) + ) + } +}) diff --git a/test/fetch/issue-2021.js b/test/fetch/issue-2021.js new file mode 100644 index 0000000..cd28a71 --- /dev/null +++ b/test/fetch/issue-2021.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('tap') +const { once } = require('events') +const { createServer } = require('http') +const { fetch } = require('../..') + +// https://github.com/nodejs/undici/issues/2021 +test('content-length header is removed on redirect', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.writeHead(302, { Location: '/redirect2' }) + res.end() + return + } + + res.end() + }).listen(0).unref() + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const body = 'a+b+c' + + await t.resolves(fetch(`http://localhost:${server.address().port}/redirect`, { + method: 'POST', + body, + headers: { + 'content-length': Buffer.byteLength(body) + } + })) +}) diff --git a/test/fetch/issue-2171.js b/test/fetch/issue-2171.js new file mode 100644 index 0000000..b04ae0e --- /dev/null +++ b/test/fetch/issue-2171.js @@ -0,0 +1,25 @@ +'use strict' + +const { fetch } = require('../..') +const { DOMException } = require('../../lib/fetch/constants') +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') + +test('error reason is forwarded - issue #2171', { skip: !AbortSignal.timeout }, async (t) => { + const server = createServer(() => {}).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const timeout = AbortSignal.timeout(100) + await t.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: timeout + }), + { + name: 'TimeoutError', + code: DOMException.TIMEOUT_ERR + } + ) +}) diff --git a/test/fetch/issue-2242.js b/test/fetch/issue-2242.js new file mode 100644 index 0000000..fe70412 --- /dev/null +++ b/test/fetch/issue-2242.js @@ -0,0 +1,8 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') + +test('fetch with signal already aborted', async (t) => { + await t.rejects(fetch('http://localhost', { signal: AbortSignal.abort('Already aborted') }), 'Already aborted') +}) diff --git a/test/fetch/issue-2318.js b/test/fetch/issue-2318.js new file mode 100644 index 0000000..e4f610d --- /dev/null +++ b/test/fetch/issue-2318.js @@ -0,0 +1,25 @@ +'use strict' + +const { test } = require('tap') +const { once } = require('events') +const { createServer } = require('http') +const { fetch } = require('../..') + +test('Undici overrides user-provided `Host` header', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.equal(req.headers.host, `localhost:${server.address().port}`) + + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: { + host: 'www.idk.org' + } + }) +}) diff --git a/test/fetch/issue-node-46525.js b/test/fetch/issue-node-46525.js new file mode 100644 index 0000000..6fd9810 --- /dev/null +++ b/test/fetch/issue-node-46525.js @@ -0,0 +1,28 @@ +'use strict' + +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') +const { fetch } = require('../..') + +// https://github.com/nodejs/node/issues/46525 +test('No warning when reusing AbortController', async (t) => { + function onWarning (error) { + t.error(error, 'Got warning') + } + + const server = createServer((req, res) => res.end()).listen(0) + + await once(server, 'listening') + + process.on('warning', onWarning) + t.teardown(() => { + process.off('warning', onWarning) + return server.close() + }) + + const controller = new AbortController() + for (let i = 0; i < 15; i++) { + await fetch(`http://localhost:${server.address().port}`, { signal: controller.signal }) + } +}) diff --git a/test/fetch/iterators.js b/test/fetch/iterators.js new file mode 100644 index 0000000..6c6761d --- /dev/null +++ b/test/fetch/iterators.js @@ -0,0 +1,140 @@ +'use strict' + +const { test } = require('tap') +const { Headers, FormData } = require('../..') + +test('Implements " Iterator" properly', (t) => { + t.test('all Headers iterators implement Headers Iterator', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = headers[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + t.ok(gen.constructor === Object) + t.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + t.equal(gen.__proto__[Symbol.toStringTag], 'Headers Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor) + // eslint-disable-next-line no-proto + t.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + + t.end() + }) + + t.test('all FormData iterators implement FormData Iterator', (t) => { + const fd = new FormData() + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = fd[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + t.ok(gen.constructor === Object) + t.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + t.equal(gen.__proto__[Symbol.toStringTag], 'FormData Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor) + // eslint-disable-next-line no-proto + t.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + + t.end() + }) + + t.test('Iterator symbols are properly set', (t) => { + t.test('Headers', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + t.equal(typeof gen[Symbol.toStringTag], 'string') + t.equal(typeof gen[Symbol.iterator], 'function') + t.end() + }) + + t.test('FormData', (t) => { + const fd = new FormData() + const gen = fd.entries() + + t.equal(typeof gen[Symbol.toStringTag], 'string') + t.equal(typeof gen[Symbol.iterator], 'function') + t.end() + }) + + t.end() + }) + + t.test('Iterator does not inherit Generator prototype methods', (t) => { + t.test('Headers', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + t.equal(gen.return, undefined) + t.equal(gen.throw, undefined) + t.equal(typeof gen.next, 'function') + + t.end() + }) + + t.test('FormData', (t) => { + const fd = new FormData() + const gen = fd.entries() + + t.equal(gen.return, undefined) + t.equal(gen.throw, undefined) + t.equal(typeof gen.next, 'function') + + t.end() + }) + + t.end() + }) + + t.test('Symbol.iterator', (t) => { + // Headers + const headerValues = new Headers([['a', 'b']]).entries()[Symbol.iterator]() + t.same(Array.from(headerValues), [['a', 'b']]) + + // FormData + const formdata = new FormData() + formdata.set('a', 'b') + const formdataValues = formdata.entries()[Symbol.iterator]() + t.same(Array.from(formdataValues), [['a', 'b']]) + + t.end() + }) + + t.test('brand check', (t) => { + // Headers + t.throws(() => { + const gen = new Headers().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + + // FormData + t.throws(() => { + const gen = new FormData().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + + t.end() + }) + + t.end() +}) diff --git a/test/fetch/jsdom-abortcontroller-1910-1464495619.js b/test/fetch/jsdom-abortcontroller-1910-1464495619.js new file mode 100644 index 0000000..e5a86ab --- /dev/null +++ b/test/fetch/jsdom-abortcontroller-1910-1464495619.js @@ -0,0 +1,26 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') +const { JSDOM } = require('jsdom') + +// https://github.com/nodejs/undici/pull/1910#issuecomment-1464495619 +test('third party AbortControllers', async (t) => { + const server = createServer((_, res) => res.end()).listen(0) + + const { AbortController } = new JSDOM().window + let controller = new AbortController() + + t.teardown(() => { + controller.abort() + controller = null + return server.close() + }) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + })) +}) diff --git a/test/fetch/redirect-cross-origin-header.js b/test/fetch/redirect-cross-origin-header.js new file mode 100644 index 0000000..8c3e674 --- /dev/null +++ b/test/fetch/redirect-cross-origin-header.js @@ -0,0 +1,50 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') + +test('Cross-origin redirects clear forbidden headers', async (t) => { + t.plan(6) + + const server1 = createServer((req, res) => { + t.equal(req.headers.cookie, undefined) + t.equal(req.headers.authorization, undefined) + t.equal(req.headers['proxy-authorization'], undefined) + + res.end('redirected') + }).listen(0) + + const server2 = createServer((req, res) => { + t.equal(req.headers.authorization, 'test') + t.equal(req.headers.cookie, 'ddd=dddd') + + res.writeHead(302, { + ...req.headers, + Location: `http://localhost:${server1.address().port}` + }) + res.end() + }).listen(0) + + t.teardown(() => { + server1.close() + server2.close() + }) + + await Promise.all([ + once(server1, 'listening'), + once(server2, 'listening') + ]) + + const res = await fetch(`http://localhost:${server2.address().port}`, { + headers: { + Authorization: 'test', + Cookie: 'ddd=dddd', + 'Proxy-Authorization': 'test' + } + }) + + const text = await res.text() + t.equal(text, 'redirected') +}) diff --git a/test/fetch/redirect.js b/test/fetch/redirect.js new file mode 100644 index 0000000..7e3681b --- /dev/null +++ b/test/fetch/redirect.js @@ -0,0 +1,50 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') + +// https://github.com/nodejs/undici/issues/1776 +test('Redirecting with a body does not cancel the current request - #1776', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 301 + res.setHeader('location', '/redirect/') + res.write('Moved Permanently') + setTimeout(() => res.end(), 500) + return + } + + res.write(req.url) + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`) + t.equal(await resp.text(), '/redirect/') + t.ok(resp.redirected) +}) + +test('Redirecting with an empty body does not throw an error - #2027', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 307 + res.setHeader('location', '/redirect/') + res.write('Moved Permanently') + res.end() + return + } + res.write(req.url) + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`, { method: 'PUT', body: '' }) + t.equal(await resp.text(), '/redirect/') + t.ok(resp.redirected) +}) diff --git a/test/fetch/relative-url.js b/test/fetch/relative-url.js new file mode 100644 index 0000000..1a4f819 --- /dev/null +++ b/test/fetch/relative-url.js @@ -0,0 +1,110 @@ +'use strict' + +const { test, afterEach } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { + getGlobalOrigin, + setGlobalOrigin, + Response, + Request, + fetch +} = require('../..') + +afterEach(() => setGlobalOrigin(undefined)) + +test('setGlobalOrigin & getGlobalOrigin', (t) => { + t.equal(getGlobalOrigin(), undefined) + + setGlobalOrigin('http://localhost:3000') + t.same(getGlobalOrigin(), new URL('http://localhost:3000')) + + setGlobalOrigin(undefined) + t.equal(getGlobalOrigin(), undefined) + + setGlobalOrigin(new URL('http://localhost:3000')) + t.same(getGlobalOrigin(), new URL('http://localhost:3000')) + + t.throws(() => { + setGlobalOrigin('invalid.url') + }, TypeError) + + t.throws(() => { + setGlobalOrigin('wss://invalid.protocol') + }, TypeError) + + t.throws(() => setGlobalOrigin(true)) + + t.end() +}) + +test('Response.redirect', (t) => { + t.throws(() => { + Response.redirect('/relative/path', 302) + }, TypeError('Failed to parse URL from /relative/path')) + + t.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + Response.redirect('/relative/path', 302) + }) + + setGlobalOrigin('http://localhost:3000') + const response = Response.redirect('/relative/path', 302) + // See step #7 of https://fetch.spec.whatwg.org/#dom-response-redirect + t.equal(response.headers.get('location'), 'http://localhost:3000/relative/path') + + t.end() +}) + +test('new Request', (t) => { + t.throws( + () => new Request('/relative/path'), + TypeError('Failed to parse URL from /relative/path') + ) + + t.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + // eslint-disable-next-line no-new + new Request('/relative/path') + }) + + setGlobalOrigin('http://localhost:3000') + const request = new Request('/relative/path') + t.equal(request.url, 'http://localhost:3000/relative/path') + + t.end() +}) + +test('fetch', async (t) => { + await t.rejects(async () => { + await fetch('/relative/path') + }, TypeError('Failed to parse URL from /relative/path')) + + t.test('Basic fetch', async (t) => { + const server = createServer((req, res) => { + t.equal(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch('/relative/path')) + }) + + t.test('fetch return', async (t) => { + const server = createServer((req, res) => { + t.equal(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch('/relative/path') + + t.equal(response.url, `http://localhost:${server.address().port}/relative/path`) + }) +}) diff --git a/test/fetch/request.js b/test/fetch/request.js new file mode 100644 index 0000000..db2c8e8 --- /dev/null +++ b/test/fetch/request.js @@ -0,0 +1,514 @@ +/* globals AbortController */ + +'use strict' + +const { test, teardown } = require('tap') +const { + Request, + Headers, + fetch +} = require('../../') +const { + Blob: ThirdPartyBlob, + FormData: ThirdPartyFormData +} = require('formdata-node') + +const hasSignalReason = 'reason' in AbortSignal.prototype + +test('arg validation', async (t) => { + // constructor + t.throws(() => { + // eslint-disable-next-line + new Request() + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', 0) + }, TypeError) + t.throws(() => { + const url = new URL('http://asd') + url.password = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + t.throws(() => { + const url = new URL('http://asd') + url.username = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + t.doesNotThrow(() => { + // eslint-disable-next-line + new Request('http://asd', undefined) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: {} + }) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: 1 + }) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'navigate' + }) + }) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + referrerPolicy: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + credentials: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + cache: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'agjhagnaöööö' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'TRACE' + }) + }, TypeError) + + t.throws(() => { + Request.prototype.destination.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.referrer.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.referrerPolicy.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.mode.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.credentials.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.cache.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.redirect.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.integrity.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.keepalive.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.isReloadNavigation.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.isHistoryNavigation.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.signal.toString() + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.body + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.bodyUsed + }, TypeError) + + t.throws(() => { + Request.prototype.clone.call(null) + }, TypeError) + + t.doesNotThrow(() => { + Request.prototype[Symbol.toStringTag].charAt(0) + }) + + for (const method of [ + 'text', + 'json', + 'arrayBuffer', + 'blob', + 'formData' + ]) { + await t.rejects(async () => { + await new Request('http://localhost')[method].call({ + blob () { + return { + text () { + return Promise.resolve('emulating this') + } + } + } + }) + }, TypeError) + } + + t.end() +}) + +test('undefined window', t => { + t.doesNotThrow(() => new Request('http://asd', { window: undefined })) + t.end() +}) + +test('undefined body', t => { + const req = new Request('http://asd', { body: undefined }) + t.equal(req.body, null) + t.end() +}) + +test('undefined method', t => { + const req = new Request('http://asd', { method: undefined }) + t.equal(req.method, 'GET') + t.end() +}) + +test('undefined headers', t => { + const req = new Request('http://asd', { headers: undefined }) + t.equal([...req.headers.entries()].length, 0) + t.end() +}) + +test('undefined referrer', t => { + const req = new Request('http://asd', { referrer: undefined }) + t.equal(req.referrer, 'about:client') + t.end() +}) + +test('undefined referrerPolicy', t => { + const req = new Request('http://asd', { referrerPolicy: undefined }) + t.equal(req.referrerPolicy, '') + t.end() +}) + +test('undefined mode', t => { + const req = new Request('http://asd', { mode: undefined }) + t.equal(req.mode, 'cors') + t.end() +}) + +test('undefined credentials', t => { + const req = new Request('http://asd', { credentials: undefined }) + t.equal(req.credentials, 'same-origin') + t.end() +}) + +test('undefined cache', t => { + const req = new Request('http://asd', { cache: undefined }) + t.equal(req.cache, 'default') + t.end() +}) + +test('undefined redirect', t => { + const req = new Request('http://asd', { redirect: undefined }) + t.equal(req.redirect, 'follow') + t.end() +}) + +test('undefined keepalive', t => { + const req = new Request('http://asd', { keepalive: undefined }) + t.equal(req.keepalive, false) + t.end() +}) + +test('undefined integrity', t => { + const req = new Request('http://asd', { integrity: undefined }) + t.equal(req.integrity, '') + t.end() +}) + +test('null integrity', t => { + const req = new Request('http://asd', { integrity: null }) + t.equal(req.integrity, 'null') + t.end() +}) + +test('undefined signal', t => { + const req = new Request('http://asd', { signal: undefined }) + t.equal(req.signal.aborted, false) + t.end() +}) + +test('pre aborted signal', t => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }) + t.equal(req.signal.aborted, true) + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } + t.end() +}) + +test('post aborted signal', t => { + t.plan(2) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }) + t.equal(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } else { + t.pass() + } + }, { once: true }) + ac.abort('gwak') +}) + +test('pre aborted signal cloned', t => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }).clone() + t.equal(req.signal.aborted, true) + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } + t.end() +}) + +test('URLSearchParams body with Headers object - issue #1407', async (t) => { + const body = new URLSearchParams({ + abc: 123 + }) + + const request = new Request( + 'http://localhost', + { + method: 'POST', + body, + headers: { + Authorization: 'test' + } + } + ) + + t.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded;charset=UTF-8') + t.equal(request.headers.get('authorization'), 'test') + t.equal(await request.text(), 'abc=123') +}) + +test('post aborted signal cloned', t => { + t.plan(2) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }).clone() + t.equal(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } else { + t.pass() + } + }, { once: true }) + ac.abort('gwak') +}) + +test('Passing headers in init', (t) => { + // https://github.com/nodejs/undici/issues/1400 + t.test('Headers instance', (t) => { + const req = new Request('http://localhost', { + headers: new Headers({ key: 'value' }) + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.test('key:value object', (t) => { + const req = new Request('http://localhost', { + headers: { key: 'value' } + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.test('[key, value][]', (t) => { + const req = new Request('http://localhost', { + headers: [['key', 'value']] + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.end() +}) + +test('Symbol.toStringTag', (t) => { + const req = new Request('http://localhost') + + t.equal(req[Symbol.toStringTag], 'Request') + t.equal(Request.prototype[Symbol.toStringTag], 'Request') + t.end() +}) + +test('invalid RequestInit values', (t) => { + /* eslint-disable no-new */ + t.throws(() => { + new Request('http://l', { mode: 'CoRs' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { mode: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { credentials: 'OMIt' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { credentials: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { cache: 'DeFaULt' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { cache: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { redirect: 'FOllOW' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { redirect: 'random' }) + }, TypeError) + /* eslint-enable no-new */ + + t.end() +}) + +test('RequestInit.signal option', async (t) => { + t.throws(() => { + // eslint-disable-next-line no-new + new Request('http://asd', { + signal: true + }) + }, TypeError) + + await t.rejects(fetch('http://asd', { + signal: false + }), TypeError) +}) + +test('constructing Request with third party Blob body', async (t) => { + const blob = new ThirdPartyBlob(['text']) + const req = new Request('http://asd', { + method: 'POST', + body: blob + }) + t.equal(await req.text(), 'text') +}) +test('constructing Request with third party FormData body', async (t) => { + const form = new ThirdPartyFormData() + form.set('key', 'value') + const req = new Request('http://asd', { + method: 'POST', + body: form + }) + const contentType = req.headers.get('content-type').split('=') + t.equal(contentType[0], 'multipart/form-data; boundary') + t.ok((await req.text()).startsWith(`--${contentType[1]}`)) +}) + +// https://github.com/nodejs/undici/issues/2050 +test('set-cookie headers get cleared when passing a Request as first param', (t) => { + const req1 = new Request('http://localhost', { + headers: { + 'set-cookie': 'a=1' + } + }) + + t.same([...req1.headers], [['set-cookie', 'a=1']]) + const req2 = new Request(req1, { headers: {} }) + + t.same([...req2.headers], []) + t.same(req2.headers.getSetCookie(), []) + t.end() +}) + +// https://github.com/nodejs/undici/issues/2124 +test('request.referrer', (t) => { + for (const referrer of ['about://client', 'about://client:1234']) { + const request = new Request('http://a', { referrer }) + + t.equal(request.referrer, 'about:client') + } + + t.end() +}) + +// https://github.com/nodejs/undici/issues/2445 +test('Clone the set-cookie header when Request is passed as the first parameter and no header is passed.', (t) => { + t.plan(2) + const request = new Request('http://localhost', { headers: { 'set-cookie': 'A' } }) + const request2 = new Request(request) + request2.headers.append('set-cookie', 'B') + t.equal(request.headers.getSetCookie().join(', '), request.headers.get('set-cookie')) + t.equal(request2.headers.getSetCookie().join(', '), request2.headers.get('set-cookie')) +}) + +// Tests for optimization introduced in https://github.com/nodejs/undici/pull/2456 +test('keys to object prototypes method', (t) => { + t.plan(1) + const request = new Request('http://localhost', { method: 'hasOwnProperty' }) + t.ok(typeof request.method === 'string') +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + t.plan(1) + const request = new Request('http://localhost', { body: new SharedArrayBuffer(0), method: 'POST' }) + t.equal(await request.text(), '[object SharedArrayBuffer]') +}) + +teardown(() => process.exit()) diff --git a/test/fetch/resource-timing.js b/test/fetch/resource-timing.js new file mode 100644 index 0000000..d266f28 --- /dev/null +++ b/test/fetch/resource-timing.js @@ -0,0 +1,72 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { nodeMajor, nodeMinor } = require('../../lib/core/util') +const { fetch } = require('../..') + +const { + PerformanceObserver, + performance +} = require('perf_hooks') + +const skip = nodeMajor < 18 || (nodeMajor === 18 && nodeMinor < 2) + +test('should create a PerformanceResourceTiming after each fetch request', { skip }, (t) => { + t.plan(8) + + const obs = new PerformanceObserver(list => { + const expectedResourceEntryName = `http://localhost:${server.address().port}/` + + const entries = list.getEntries() + t.equal(entries.length, 1) + const [entry] = entries + t.same(entry.name, expectedResourceEntryName) + t.strictSame(entry.entryType, 'resource') + + t.ok(entry.duration >= 0) + t.ok(entry.startTime >= 0) + + const entriesByName = list.getEntriesByName(expectedResourceEntryName) + t.equal(entriesByName.length, 1) + t.strictSame(entriesByName[0], entry) + + obs.disconnect() + performance.clearResourceTimings() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame('ok', await body.text()) + }) + + t.teardown(server.close.bind(server)) +}) + +test('should include encodedBodySize in performance entry', { skip }, (t) => { + t.plan(4) + const obs = new PerformanceObserver(list => { + const [entry] = list.getEntries() + t.equal(entry.encodedBodySize, 2) + t.equal(entry.decodedBodySize, 2) + t.equal(entry.transferSize, 2 + 300) + + obs.disconnect() + performance.clearResourceTimings() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame('ok', await body.text()) + }) + + t.teardown(server.close.bind(server)) +}) diff --git a/test/fetch/response-json.js b/test/fetch/response-json.js new file mode 100644 index 0000000..6244fbf --- /dev/null +++ b/test/fetch/response-json.js @@ -0,0 +1,113 @@ +'use strict' + +const { test } = require('tap') +const { Response } = require('../../') + +// https://github.com/web-platform-tests/wpt/pull/32825/ + +const APPLICATION_JSON = 'application/json' +const FOO_BAR = 'foo/bar' + +const INIT_TESTS = [ + [undefined, 200, '', APPLICATION_JSON, {}], + [{ status: 400 }, 400, '', APPLICATION_JSON, {}], + [{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}], + [{ headers: {} }, 200, '', APPLICATION_JSON, {}], + [{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}], + [{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }] +] + +test('Check response returned by static json() with init', async (t) => { + for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) { + const response = Response.json('hello world', init) + t.equal(response.type, 'default', "Response's type is default") + t.equal(response.status, expectedStatus, "Response's status is " + expectedStatus) + t.equal(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText)) + t.equal(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType) + for (const key in expectedHeaders) { + t.equal(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key])) + } + + const data = await response.json() + t.equal(data, 'hello world', "Response's body is 'hello world'") + } + + t.end() +}) + +test('Throws TypeError when calling static json() with an invalid status', (t) => { + const nullBodyStatus = [204, 205, 304] + + for (const status of nullBodyStatus) { + t.throws(() => { + Response.json('hello world', { status }) + }, TypeError, `Throws TypeError when calling static json() with a status of ${status}`) + } + + t.end() +}) + +test('Check static json() encodes JSON objects correctly', async (t) => { + const response = Response.json({ foo: 'bar' }) + const data = await response.json() + t.equal(typeof data, 'object', "Response's json body is an object") + t.equal(data.foo, 'bar', "Response's json body is { foo: 'bar' }") + + t.end() +}) + +test('Check static json() throws when data is not encodable', (t) => { + t.throws(() => { + Response.json(Symbol('foo')) + }, TypeError) + + t.end() +}) + +test('Check static json() throws when data is circular', (t) => { + const a = { b: 1 } + a.a = a + + t.throws(() => { + Response.json(a) + }, TypeError) + + t.end() +}) + +test('Check static json() propagates JSON serializer errors', (t) => { + class CustomError extends Error { + name = 'CustomError' + } + + t.throws(() => { + Response.json({ get foo () { throw new CustomError('bar') } }) + }, CustomError) + + t.end() +}) + +// note: these tests are not part of any WPTs +test('unserializable values', (t) => { + t.throws(() => { + Response.json(Symbol('symbol')) + }, TypeError) + + t.throws(() => { + Response.json(undefined) + }, TypeError) + + t.throws(() => { + Response.json() + }, TypeError) + + t.end() +}) + +test('invalid init', (t) => { + t.throws(() => { + Response.json(null, 3) + }, TypeError) + + t.end() +}) diff --git a/test/fetch/response.js b/test/fetch/response.js new file mode 100644 index 0000000..422c7ef --- /dev/null +++ b/test/fetch/response.js @@ -0,0 +1,257 @@ +'use strict' + +const { test } = require('tap') +const { + Response +} = require('../../') +const { ReadableStream } = require('stream/web') +const { + Blob: ThirdPartyBlob, + FormData: ThirdPartyFormData +} = require('formdata-node') + +test('arg validation', async (t) => { + // constructor + t.throws(() => { + // eslint-disable-next-line + new Response(null, 0) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 99 + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 600 + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: '600' + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + statusText: '\u0000' + }) + }, TypeError) + + for (const nullStatus of [204, 205, 304]) { + t.throws(() => { + // eslint-disable-next-line + new Response(new ArrayBuffer(16), { + status: nullStatus + }) + }, TypeError) + } + + t.doesNotThrow(() => { + Response.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.throws(() => { + Response.prototype.type.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.url.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.redirected.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.status.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.ok.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.statusText.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.headers.toString() + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.body + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.bodyUsed + }, TypeError) + + t.throws(() => { + Response.prototype.clone.call(null) + }, TypeError) + + await t.rejects(async () => { + await new Response('http://localhost').text.call({ + blob () { + return { + text () { + return Promise.resolve('emulating response.blob()') + } + } + } + }) + }, TypeError) + + t.end() +}) + +test('response clone', (t) => { + // https://github.com/nodejs/undici/issues/1122 + const response1 = new Response(null, { status: 201 }) + const response2 = new Response(undefined, { status: 201 }) + + t.equal(response1.body, response1.clone().body) + t.equal(response2.body, response2.clone().body) + t.equal(response2.body, null) + t.end() +}) + +test('Symbol.toStringTag', (t) => { + const resp = new Response() + + t.equal(resp[Symbol.toStringTag], 'Response') + t.equal(Response.prototype[Symbol.toStringTag], 'Response') + t.end() +}) + +test('async iterable body', async (t) => { + const asyncIterable = { + async * [Symbol.asyncIterator] () { + yield 'a' + yield 'b' + yield 'c' + } + } + + const response = new Response(asyncIterable) + t.equal(await response.text(), 'abc') + t.end() +}) + +// https://github.com/nodejs/node/pull/43752#issuecomment-1179678544 +test('Modifying headers using Headers.prototype.set', (t) => { + const response = new Response('body', { + headers: { + 'content-type': 'test/test', + 'Content-Encoding': 'hello/world' + } + }) + + const response2 = response.clone() + + response.headers.set('content-type', 'application/wasm') + response.headers.set('Content-Encoding', 'world/hello') + + t.equal(response.headers.get('content-type'), 'application/wasm') + t.equal(response.headers.get('Content-Encoding'), 'world/hello') + + response2.headers.delete('content-type') + response2.headers.delete('Content-Encoding') + + t.equal(response2.headers.get('content-type'), null) + t.equal(response2.headers.get('Content-Encoding'), null) + + t.end() +}) + +// https://github.com/nodejs/node/issues/43838 +test('constructing a Response with a ReadableStream body', { skip: process.version.startsWith('v16.') }, async (t) => { + const text = '{"foo":"bar"}' + const uint8 = new TextEncoder().encode(text) + + t.test('Readable stream with Uint8Array chunks', async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + + t.equal(await response1.text(), text) + t.same(await response2.arrayBuffer(), uint8.buffer) + t.same(await response3.json(), JSON.parse(text)) + + t.end() + }) + + t.test('Readable stream with non-Uint8Array chunks', async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(text) // string + controller.close() + } + }) + + const response = new Response(readable) + + await t.rejects(response.text(), TypeError) + + t.end() + }) + + t.test('Readable with ArrayBuffer chunk still throws', { skip: process.version.startsWith('v16.') }, async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8.buffer) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + // const response4 = response1.clone() + + await t.rejects(response1.arrayBuffer(), TypeError) + await t.rejects(response2.text(), TypeError) + await t.rejects(response3.json(), TypeError) + // TODO: on Node v16.8.0, this throws a TypeError + // because the body is detected as disturbed. + // await t.rejects(response4.blob(), TypeError) + + t.end() + }) + + t.end() +}) + +test('constructing Response with third party Blob body', async (t) => { + const blob = new ThirdPartyBlob(['text']) + const res = new Response(blob) + t.equal(await res.text(), 'text') +}) +test('constructing Response with third party FormData body', async (t) => { + const form = new ThirdPartyFormData() + form.set('key', 'value') + const res = new Response(form) + const contentType = res.headers.get('content-type').split('=') + t.equal(contentType[0], 'multipart/form-data; boundary') + t.ok((await res.text()).startsWith(`--${contentType[1]}`)) +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + t.plan(1) + const response = new Response(new SharedArrayBuffer(0)) + t.equal(await response.text(), '[object SharedArrayBuffer]') +}) diff --git a/test/fetch/user-agent.js b/test/fetch/user-agent.js new file mode 100644 index 0000000..2e37ea5 --- /dev/null +++ b/test/fetch/user-agent.js @@ -0,0 +1,32 @@ +'use strict' + +const { test, skip } = require('tap') +const events = require('events') +const http = require('http') +const undici = require('../../') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const nodeBuild = require('../../undici-fetch.js') + +test('user-agent defaults correctly', async (t) => { + const server = http.createServer((req, res) => { + res.end(JSON.stringify({ userAgentHeader: req.headers['user-agent'] })) + }) + t.teardown(server.close.bind(server)) + + server.listen(0) + await events.once(server, 'listening') + const url = `http://localhost:${server.address().port}` + const [nodeBuildJSON, undiciJSON] = await Promise.all([ + nodeBuild.fetch(url).then((body) => body.json()), + undici.fetch(url).then((body) => body.json()) + ]) + + t.same(nodeBuildJSON.userAgentHeader, 'node') + t.same(undiciJSON.userAgentHeader, 'undici') +}) diff --git a/test/fetch/util.js b/test/fetch/util.js new file mode 100644 index 0000000..f728d86 --- /dev/null +++ b/test/fetch/util.js @@ -0,0 +1,354 @@ +'use strict' + +const t = require('tap') +const { test } = t + +const util = require('../../lib/fetch/util') +const { HeadersList } = require('../../lib/fetch/headers') +const { createHash } = require('crypto') + +test('responseURL', (t) => { + t.plan(2) + + t.ok(util.responseURL({ + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) + t.notOk(util.responseURL({ + urlList: [] + })) +}) + +test('responseLocationURL', (t) => { + t.plan(3) + + const acceptHeaderList = new HeadersList() + acceptHeaderList.append('Accept', '*/*') + + const locationHeaderList = new HeadersList() + locationHeaderList.append('Location', 'http://asd') + + t.notOk(util.responseLocationURL({ + status: 200 + })) + t.notOk(util.responseLocationURL({ + status: 301, + headersList: acceptHeaderList + })) + t.ok(util.responseLocationURL({ + status: 301, + headersList: locationHeaderList, + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) +}) + +test('requestBadPort', (t) => { + t.plan(3) + + t.equal('allowed', util.requestBadPort({ + urlList: [new URL('https://asd')] + })) + t.equal('blocked', util.requestBadPort({ + urlList: [new URL('http://asd:7')] + })) + t.equal('blocked', util.requestBadPort({ + urlList: [new URL('https://asd:7')] + })) +}) + +// https://html.spec.whatwg.org/multipage/origin.html#same-origin +// look at examples +test('sameOrigin', (t) => { + t.test('first test', (t) => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + t.ok(util.sameOrigin(A, B)) + t.end() + }) + + t.test('second test', (t) => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '314' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '420' + } + + t.notOk(util.sameOrigin(A, B)) + t.end() + }) + + t.test('obviously shouldn\'t be equal', (t) => { + t.notOk(util.sameOrigin( + { protocol: 'http:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.org' } + )) + + t.notOk(util.sameOrigin( + { protocol: 'https:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.com' } + )) + + t.end() + }) + + t.test('file:// urls', (t) => { + // urls with opaque origins should return true + + const a = new URL('file:///C:/undici') + const b = new URL('file:///var/undici') + + t.ok(util.sameOrigin(a, b)) + t.end() + }) + + t.end() +}) + +test('isURLPotentiallyTrustworthy', (t) => { + const valid = ['http://127.0.0.1', 'http://localhost.localhost', + 'http://[::1]', 'http://adb.localhost', 'https://something.com', 'wss://hello.com', + 'file:///link/to/file.txt', 'data:text/plain;base64,randomstring', 'about:blank', 'about:srcdoc'] + const invalid = ['http://121.3.4.5:55', 'null:8080', 'something:8080'] + + t.plan(valid.length + invalid.length + 1) + t.notOk(util.isURLPotentiallyTrustworthy('string')) + + for (const url of valid) { + const instance = new URL(url) + t.ok(util.isURLPotentiallyTrustworthy(instance)) + } + + for (const url of invalid) { + const instance = new URL(url) + t.notOk(util.isURLPotentiallyTrustworthy(instance)) + } +}) + +test('setRequestReferrerPolicyOnRedirect', nested => { + nested.plan(7) + + nested.test('should set referrer policy from response headers on redirect', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'origin') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should select the first valid policy from a response', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdas, origin') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should select the first valid policy from a response#2', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'no-referrer, asdas, origin, 0943sd') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should pick the last fallback over invalid policy tokens', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'origin, asdas, asdaw34') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should set not change request referrer policy if no Referrer-Policy from initial redirect response', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'no-referrer, strict-origin-when-cross-origin') + }) + + nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => { + const initial = 'no-referrer, strict-origin-when-cross-origin' + const request = { + referrerPolicy: initial + } + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdasd') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, initial) + }) + + nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => { + const initial = 'no-referrer, strict-origin-when-cross-origin' + const request = { + referrerPolicy: initial + } + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdasd, asdasa, 12daw,') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, initial) + }) +}) + +test('parseMetadata', (t) => { + t.test('should parse valid metadata with option', (t) => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@` + const result = util.parseMetadata(validMetadata) + + t.same(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + t.end() + }) + + t.test('should parse valid metadata with non ASCII chars option', (t) => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ` + const result = util.parseMetadata(validMetadata) + + t.same(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + t.end() + }) + + t.test('should parse valid metadata without option', (t) => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}` + const result = util.parseMetadata(validMetadata) + + t.same(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + t.end() + }) + + t.test('should set hash as undefined when invalid base64 chars are provided', (t) => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const invalidHash384 = 'zifp5hE1Xl5LQQqQz[]Bq/iaq9Wb6jVb//T7EfTmbXD2aEP5c2ZdJr9YTDfcTE1ZH+' + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} sha384-${invalidHash384} sha512-${hash512}` + const result = util.parseMetadata(validMetadata) + + t.same(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: undefined }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + t.end() + }) + + t.end() +}) diff --git a/test/fixed-queue.js b/test/fixed-queue.js new file mode 100644 index 0000000..812f421 --- /dev/null +++ b/test/fixed-queue.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('tap') + +const FixedQueue = require('../lib/node/fixed-queue') + +test('fixed queue 1', (t) => { + t.plan(5) + + const queue = new FixedQueue() + t.equal(queue.head, queue.tail) + t.ok(queue.isEmpty()) + queue.push('a') + t.ok(!queue.isEmpty()) + t.equal(queue.shift(), 'a') + t.equal(queue.shift(), null) +}) + +test('fixed queue 2', (t) => { + t.plan(7 + 2047) + + const queue = new FixedQueue() + for (let i = 0; i < 2047; i++) { + queue.push('a') + } + t.ok(queue.head.isFull()) + queue.push('a') + t.ok(!queue.head.isFull()) + + t.not(queue.head, queue.tail) + for (let i = 0; i < 2047; i++) { + t.equal(queue.shift(), 'a') + } + t.equal(queue.head, queue.tail) + t.ok(!queue.isEmpty()) + t.equal(queue.shift(), 'a') + t.ok(queue.isEmpty()) +}) diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 0000000..c126543 --- /dev/null +++ b/test/fixtures/ca.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIIChDCCAe2gAwIBAgIJAMsVOuISYJ/GMA0GCSqGSIb3DQEBCwUAMHoxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCU0YxDzANBgNVBAoMBkpveWVu +dDEQMA4GA1UECwwHTm9kZS5qczEMMAoGA1UEAwwDY2ExMSAwHgYJKoZIhvcNAQkB +FhFyeUB0aW55Y2xvdWRzLm9yZzAgFw0xODExMTYxODQyMjBaGA8yMjkyMDgzMDE4 +NDIyMFowejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEP +MA0GA1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTEx +IDAeBgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQDrNdKjVKhbxKbrDRLdy45u9vsU3IH8C3qFcLF5wqf+g7OC +vMOOrFDM6mL5iYwuYaLRvAtsC0mtGPzBGyFflxGhiBYaOhi7nCKEsUkFuNYlCzX+ +FflT04JYT3qWPLL7rT32GXpABND/8DEnj5D5liYYNR05PjV1fUnGg1gPqXVxbwID +AQABoxAwDjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4GBAHhsWFy6m6VO +AjK14n0XCSM66ltk9qMKpOryXneLhmmkOQbJd7oavueUWzMdszWLMKhrBoXjmvuW +QceutP9IUq1Kzw7a/B+lLPD90xfLMr7tNLAxZoJmq/NAUI63M3nJGpX0HkjnYwoU +ekzNkKt5TggwcqqzK+cCSG1wDvJ+wjiD +-----END CERTIFICATE----- diff --git a/test/fixtures/cert.pem b/test/fixtures/cert.pem new file mode 100644 index 0000000..664d00c --- /dev/null +++ b/test/fixtures/cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC2DCCAkGgAwIBAgIJAOzJuFYnDamoMA0GCSqGSIb3DQEBCwUAMHoxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTELMAkGA1UEBwwCU0YxDzANBgNVBAoMBkpveWVu +dDEQMA4GA1UECwwHTm9kZS5qczEMMAoGA1UEAwwDY2ExMSAwHgYJKoZIhvcNAQkB +FhFyeUB0aW55Y2xvdWRzLm9yZzAgFw0xODExMTYxODQyMjFaGA8yMjkyMDgzMDE4 +NDIyMVowfTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEP +MA0GA1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQ8wDQYDVQQDDAZhZ2Vu +dDExIDAeBgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnMIGfMA0GCSqGSIb3 +DQEBAQUAA4GNADCBiQKBgQDvVEBwFjfiirsDjlZB+CjYNMNCqdJe27hqK/b72AnL +jgN6mLcXCOABJC5N61TGFkiF9Zndh6IyFXRZVb4gQX4zxNDRuAydo95BmiYHGV0v +t1ZXsLv7XrfQu6USLRtpZMe1cNULjsAB7raN+1hEN1CPMSmSjWc7MKPgv09QYJ5j +cQIDAQABo2EwXzBdBggrBgEFBQcBAQRRME8wIwYIKwYBBQUHMAGGF2h0dHA6Ly9v +Y3NwLm5vZGVqcy5vcmcvMCgGCCsGAQUFBzAChhxodHRwOi8vY2Eubm9kZWpzLm9y +Zy9jYS5jZXJ0MA0GCSqGSIb3DQEBCwUAA4GBAHrKvx2Z4fsF7b3VRgiIbdbFCfxY +ICvoJ0+BObYPjqIZZm9+/5c36SpzKzGO9CN9qUEj3KxPmijnb+Zjsm1CSCrG1m04 +C73+AjAIPnQ+eWZnF1K4L2kuEDTpv8nQzYKYiGxsmW58PSMeAq1TmaFwtSW3TxHX +7ROnqBX0uXQlOo1m +-----END CERTIFICATE----- diff --git a/test/fixtures/client-ca-crt.pem b/test/fixtures/client-ca-crt.pem new file mode 100644 index 0000000..3abfd04 --- /dev/null +++ b/test/fixtures/client-ca-crt.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICqDCCAZACCQC0Hman8CosTDANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApu +b2RlanMub3JnMCAXDTIyMDcxOTE2MzQwMloYDzIxMjIwNzIwMTYzNDAyWjAVMRMw +EQYDVQQDDApub2RlanMub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAyrmvIOhsVJAinUZ0Np4o5cPz09arWAZnnDsMnU0d+NtI0lWOFCnpzJbER9eB +gJpRkOdkcsQFr0OcalExG4lQrj+yGdtLGSXVcE0aNsVSBNbNgaLbOFWfpA4c7pTF +SBLJdJ7pZ2LDrM2mXaQA30di3INsZOvuTnDSAEE8bwxnM7jDnTCOGD4asgzgknHa +NqYWJqrfEPoMcEtThX9XjBLlRq5X3YFAR8SRbMQDt2xbDLWO8mGo/y4Ezp+ol9dP +OdkX3f728EIgfk8fM7rpvHzJb8E6NPdKK/kqCjQxRJ4RMsRqKwiTgPcEqut0L6Kg +jGoDvOnc3dZ2QBrxGTYPrgZF2QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA2DC4n +GNqQIABC82e3CovVH/LYB8M/PaqMwmXDI8kAKwk3j3lTHYD0WIyaFtCL4z/2GyDs +sgRmMlx5xVgXNv+8e793TMOqJ/0zixijguatR8r9GWdPAPhqCyCNrmUA26eyHEUV +Hx9mU7RNjv+qVe7fNXBkDorsyecclnDcxUd9k2C+RbjitnSKvhP64XqxAGk49HUH +3gw5uZw9uVlmD/dPSeKeSO4TX1HECH+WmPBKrBrcFGXNwGNzst8pFe3YVLLuseIq +4d5ngaOThGzVDJdsGIxhDfDBfH5FzDTMgEJxQQ3yXYwPR3zF4Ntn13oDkIu/vgbH +4n1eYIau6/1Y9OLX +-----END CERTIFICATE----- diff --git a/test/fixtures/client-crt-2048.pem b/test/fixtures/client-crt-2048.pem new file mode 100644 index 0000000..6d07ec1 --- /dev/null +++ b/test/fixtures/client-crt-2048.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIUF2CLbUCxPnxARRlO7pANiXtZoLIwDQYJKoZIhvcNAQEL +BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X +DTIyMDYwOTE0Mzc0N1oXDTI1MDMwNDE0Mzc0N1owWTELMAkGA1UEBhMCQVUxEzAR +BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5 +IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA4PbcFnMY0FC1wzsyMf04GhOx/KNcOalHu4Wy76Wys+WoJ6hO5z87 +ZIcmsg0hbys1l6DGxloTXeZwcBDoOndUg3FBZvAXRKimhXA7Qf31a9efq9GXic2W +7Kyn1jPa724Vkr/zzlWb5I/Qkk6xcQmEFCDhilbMtpnPz/BwOwn/2vbcbiHNirUk +Dn+s0pUcQlin1f2AR4Jq7/K1xsqjjB6cU0chuzrwzwrglQS7jpXQxCsRaAAIZQJB +DTVQBEo/skqWwv8xABlVQgolxABIX3Wc3RUk7xRItdWCMe92/BJCGhWVXb2hUCBu +y/yz5hX9p353JlxmXEKQlhfPzhcdDv2sdwIDAQABo1MwUTAdBgNVHQ4EFgQUQ0di +dFnBDLhSDgHpM+/KBn+WmI4wHwYDVR0jBBgwFoAUQ0didFnBDLhSDgHpM+/KBn+W +mI4wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAoCQJci8G+cUF +n030frY/OgnLJXUGC2ed9Tu+dYhEE7XQkX9WO3IK8As+jGY0kKzX7ZsvWAHHbSa3 +8qmHh1vWflU9HEc0MO0Toy6Ale2/BCjs0Oy3q2vd6t9pl3Pq2JTHyJNYu44h45we +ufQ+ttylHGZSmAqeHz4yGp1xVvjbfriDYuc0kW9UTwMpdpzR9RmqQEVD4ySxpuYV +FTj/ZiY89GdIJvsz1pmAhTUcUfuMgSlWS1nt0YR4yMkFS8KqQ1iKEApjrdDCU48W +eABaPeTCUlBCFEDuKxFVPduYVVvOHtkX/8LPH3CO7EDMoSZ1iCDZ7b2+AZbwh9j+ +dXqw+WFi7w== +-----END CERTIFICATE----- diff --git a/test/fixtures/client-crt.pem b/test/fixtures/client-crt.pem new file mode 100644 index 0000000..2bd94df --- /dev/null +++ b/test/fixtures/client-crt.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCWvC2NnLEpZjANBgkqhkiG9w0BAQUFADAVMRMwEQYDVQQDDApu +b2RlanMub3JnMCAXDTIyMDcxOTE2NDE1OFoYDzIxMjIwNzIwMTY0MTU4WjARMQ8w +DQYDVQQLDAZVbmRpY2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDR +SJvCSXTHrmnGz/CN94nxgmnUD17jYzfJH+lbcJkw4RDHpb6KZ85LEijeKoYoGw+c +Z7a4LfmpIR4rcN3sJWGvafJyFx4DtLYPZiNrCaMsdMWiHbbMwrpvSsf5Fq3vVeUz +Py7wxzSRiM4VOwZ7fhCJdj2YIeQJgeIZh+NN/4mpyWehS4hQSHG+cbS4c44vkET0 +Hv48G7m+4ULFCZzmG2AIW8Drh73Wymmm3kymD3kDCAY4SDSJDArxNt6lJ3sGJGO6 +jobefLFyqvLj5544Lvk4C8hD3O+e9M3OHcdyqRXf55dZ8SIWgpoGVGXb5V5g3WL/ +ncXF87jm05pMZXqOz0wdAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAK2YxxGEDgqG +tp8uX/n0nFAj1p8sfkuD+FqYg7+PN/HYqCq6Ibrz/vVABL5Khb4qQzZN/ckJhY3k +bfwEjRTOoXMhPv+IkShMDdbTunwSQUXqeLe+qmPbLt5ZccxcYVIzEhJMlnjeJ4nk +NHg3BXt8y6mIIfY0Sv4znTkV995GHLK3Ax/Fd/2aio6aRCzkBCdaXY8j0SOzFHVy ++AvgRj04K2yBEEHd4bQTdLCJQR/gFQnGj37gXQp9I4qq+/1qj4sTs8BufnGKTDVT +/jYeycIY3l4A8/72NmDSIohaJTPwFUoXNBYywOnW71+Y05PXT45lJuaOJUf2s9iH +p/eTiEsfHsk= +-----END CERTIFICATE----- diff --git a/test/fixtures/client-key-2048.pem b/test/fixtures/client-key-2048.pem new file mode 100644 index 0000000..b7dffa6 --- /dev/null +++ b/test/fixtures/client-key-2048.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA4PbcFnMY0FC1wzsyMf04GhOx/KNcOalHu4Wy76Wys+WoJ6hO +5z87ZIcmsg0hbys1l6DGxloTXeZwcBDoOndUg3FBZvAXRKimhXA7Qf31a9efq9GX +ic2W7Kyn1jPa724Vkr/zzlWb5I/Qkk6xcQmEFCDhilbMtpnPz/BwOwn/2vbcbiHN +irUkDn+s0pUcQlin1f2AR4Jq7/K1xsqjjB6cU0chuzrwzwrglQS7jpXQxCsRaAAI +ZQJBDTVQBEo/skqWwv8xABlVQgolxABIX3Wc3RUk7xRItdWCMe92/BJCGhWVXb2h +UCBuy/yz5hX9p353JlxmXEKQlhfPzhcdDv2sdwIDAQABAoIBAFVfeaCPZ2BO8Nu5 +UFBGP48t4EL3H93GDzHsCD8IC+xXgFwkdGUvyvNYkufJMeIFbN4xJp5JusXM2Oi+ +kdL2TD1hsqdFAB+PPTqwn9xoa0XU24SSEsc6HUeOMleI8FIi3c8GR5kLRhEUPtv3 +P0GdkeEtpUohrKizcHkCTyUoo09N35MFoH3Nb1iyMd10uq0iQlusljkTuukcHstK +MZQAYYcslqzyz9468O/cvsk23Ynd5FfjLgYKmdJ09qaxm4ptnF9NNJ2cLqwElbUF +xI3H5L/t1zxdwI0xZFFgDA4Ccpeq9QsRhRJGAOV94tN+4PxWXEPeQk4PM1EFDrNU +yysi/XkCgYEA+ElKG6cWQZydsb5Tk1vdJ/k18gZa5sv+WUGXkfm9EVecftGjtKQO +c7GwHO1IsLoZkhKfPpa/oifBR97DZRzw1ManEQPS980TZYei3Y9/8uPEpvgvRmm9 +MCHA5wp6YMlkZ5VN0SBRWnPhLtZ8L2/cqHOUCQf6YsIJU9/fewufrbUCgYEA5/QU +/tDBDl/f4A2R1HlIkGd1jS//CJLCc3riy0SQxcWIq6/cqflyfvRWiax5DwcO7qfh +3WbJldu9H0IWZjBCqX0v/jHvWBzaKNQCKbFFcL76Lr8bJCwlUMTH9MOhHf3uCOHD +J7YSTVJdvgzLN8K6yFhc0gI4VYQtnQTWJENObPsCgYEAlawAq6jO5uCVw3dbhGKF +cDpwBaVFGQpyGrZKu6nUCudIpL6VtCiNubqs0tNL1ZVqIr9tFdrkTMkwX7XvDj4j +A/F49u3aOJ18iuD4Eh4WYIJjos/MF+NYM/K1CdIsMbpV94dusJmN0Tw3y/dqR2Jk +n3uFCuivTOdxngk//DnmmV0CgYEA1CXNUiZSfLg5xe4DVEc9lD3cKS8d3pSEXySk +6+8hTpHV59moRJpPG0iVIcRq0NDO2n8YOOy7MWJSPpWucPZw8h362E6Jr5hr/G20 +MLffYDh8EGdgBpyN4Kqqi/allQ3cOalrWhXP9YKBFMMU10I2nekbtESti6GiKnvy +9CXPRCMCgYBZ2w+VVdhUUBA/elbuEdfbPwIYVDAk31PYg0c9jvQVusmfD1CuY/51 +JVsF5oJSosiN7WdDIETkklth0q3lAsQBKoYYMUw54RBf6FawoumB6MVdc3u4y9Ko +l9JC9czdEqb/e0LBqFiWsrtPk9WQf2gyN1mIXQPbyTT1O1J+DvUIbQ== +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/client-key.pem b/test/fixtures/client-key.pem new file mode 100644 index 0000000..6b47524 --- /dev/null +++ b/test/fixtures/client-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0Uibwkl0x65pxs/wjfeJ8YJp1A9e42M3yR/pW3CZMOEQx6W+ +imfOSxIo3iqGKBsPnGe2uC35qSEeK3Dd7CVhr2nychceA7S2D2YjawmjLHTFoh22 +zMK6b0rH+Rat71XlMz8u8Mc0kYjOFTsGe34QiXY9mCHkCYHiGYfjTf+JqclnoUuI +UEhxvnG0uHOOL5BE9B7+PBu5vuFCxQmc5htgCFvA64e91spppt5Mpg95AwgGOEg0 +iQwK8TbepSd7BiRjuo6G3nyxcqry4+eeOC75OAvIQ9zvnvTNzh3HcqkV3+eXWfEi +FoKaBlRl2+VeYN1i/53FxfO45tOaTGV6js9MHQIDAQABAoIBACOp2+Ef42ajsiLP +DI8kv70IHECm3eSh47/CUGHkrjZGJDXhaLbtOZpRXeV+GZ57/g0JH3oDW6gWnK2K +bkbvl9XsmAQZLGQ1R1EYdrCm08efno4hwiTiiiKs+6bW1o0Sdhxlh/o/+BVU2smD +ZXdl5CuImrZyEAoOuBjhrzp7cVodSOYYK2RIAL35oAtKLR6NE40XGcxQSCdm+1eU +PzRo8TimQxujyIHrd1QV2FirmLfDFGg3LN8DS72n26bhvDg3PF6PVMF20BKTDqiu +xAyKg3weBsee2QoyegDRdgTD1PvjwWqqnsntPbvY5V8PR1DDmssfotYToNPVuJd2 +6usmBAECgYEA/21NZPZJdxRKwCiWXoqBUIY0VFajxihVxZ9pIZPXOFhpGmyj/jf6 +jBiHAqtucRdABtNxqsztGbEzJsMyNv7MqEVTAWUPH804OwW/C6Z2011GZ1AUN05n +zTxPR4eCYlxvSM+wwC8q+4mSo7hAZj5HltUI0kfEahZnGXqG4FRC1TUCgYEA0cDO +DuTrytk6EoYYCsS7ps87MYUlU97RHFrRGwf+V1Rz2RCz+XAkYCI1/tOpb0VeF1de +fX1mlM3edkLX2ooylYxv5HKPpICzPXeGK/u/HaJBRyZEq6Ms0HK8XyJOdG/UyuiZ +p9nc8eaZYvco24bT4dWe5oZ43mnydAwyK2tOgEkCgYEA/blJg9zSJSNXDYJDvC3B +PofRO2XE0XYHnYM4H06IH0RTQxhf3oskqj1C/3fjARujUiR/aLafX0ISGZMUMmTw +TsZuKZiFaYWlMZwHpj75EgQ5hy6YpkeP/OLHrboB3ksLkDweywkPnUWPEGpaLjX3 +TvDXDmqTxP3z8+8uQ2/v43ECgYB5/3BaTV+vviT+vSuip8aVQRcmuFB7ta9elJvm +4wFV/fLbn9FuFYGywHMzYhy8cVZGsTRuPM+7YPoxQrOVkqfVP7ec4d0WSxz1dV1+ +m5APRl49ac6rHd9k5jcWBjgnlRvpYNxuOlM+B2fTnfoPpR37zmn7nt8STgEM6kML +6f/gsQKBgFJH95hEgqhfEHmP23+ZWH0Dl7zD5sJJe4CYTgYriNeKKzpz2G6OVv+U +xNc8eGbnr4raPTxCCLKz6XJhuQuPQDpkoHvkhjOqZ5Tbb4fCaLdcVE0vwqBE1gGk +ryKSvahgHIykq3+RYpL4u2xypx81IBOMk7EM++Z6gdYMq0ZTN/fL +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem new file mode 100644 index 0000000..fe750de --- /dev/null +++ b/test/fixtures/key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDvVEBwFjfiirsDjlZB+CjYNMNCqdJe27hqK/b72AnLjgN6mLcX +COABJC5N61TGFkiF9Zndh6IyFXRZVb4gQX4zxNDRuAydo95BmiYHGV0vt1ZXsLv7 +XrfQu6USLRtpZMe1cNULjsAB7raN+1hEN1CPMSmSjWc7MKPgv09QYJ5jcQIDAQAB +AoGAbqk3TlyHpKFfDarf6Yr0X9wtuQJK+n+ACt+fSR3AkbVtmF9KsUTyRrTTEEZT +IXCmQgKpDYysi5nt/WyvB70gu6xGYbT6PzZaf1RmcpWd1pLcdyBOppY6y7nTMZA3 +BVFfmIPSmAvtCuzZwQFFnNoKH3d6cqna+ZQJ0zvCLCSLcw0CQQD6tswNlhCIfguh +tvhw7hJB5vZPWWEzyTQl8nVdY6SbxAT8FTx0UjxsKgOiJFzAGAVoCi40oRKIHhrw +pKwHsEqTAkEA9GABbi2xqAmhPn66e0AiU8t2uv69PISBSt2tXbUAburJFj+4rYZW +71QIbSKEYceveb7wm0NP+adgZqJlxn7oawJBAOjfK4+fCIJPWWx+8Cqs5yZxae1w +HrokNBzfJSZ2bCoGm36uFvYQgHETYUaUsdX3OeZWNm7KAdWO6QUGX4fQtqMCQGXv +OgmEY+utAKZ55D2PFgKQB1me8r6wouHgr/U7kA+0Peba86TmOZMhIVaspD3JNqf4 +/pI1NMH1kF+fdAalXzsCQQCelwr9I3FWhx336CWrfAY20xbiMOWMyAhrjVrexgUD +53Y6AhSaRC725pZTgO2PQ4AjkGLIP61sZKgTrXS85KmJ +-----END RSA PRIVATE KEY----- diff --git a/test/fuzzing/client/client-fuzz-body.js b/test/fuzzing/client/client-fuzz-body.js new file mode 100644 index 0000000..6643dda --- /dev/null +++ b/test/fuzzing/client/client-fuzz-body.js @@ -0,0 +1,28 @@ +'use strict' + +const { request, errors } = require('../../..') + +const acceptableCodes = [ + 'ERR_INVALID_ARG_TYPE' +] + +// TODO: could make this a class with some inbuilt functionality that we can inherit +async function fuzz (netServer, results, buf) { + const body = buf + results.body = body + try { + const data = await request(`http://localhost:${netServer.address().port}`, { body }) + data.body.destroy().on('error', () => {}) + } catch (err) { + results.err = err + // Handle any undici errors + if (Object.values(errors).some(undiciError => err instanceof undiciError)) { + // Okay error + } else if (!acceptableCodes.includes(err.code)) { + console.log(`=== Headers: ${JSON.stringify(body)} ===`) + throw err + } + } +} + +module.exports = fuzz diff --git a/test/fuzzing/client/client-fuzz-headers.js b/test/fuzzing/client/client-fuzz-headers.js new file mode 100644 index 0000000..84f3390 --- /dev/null +++ b/test/fuzzing/client/client-fuzz-headers.js @@ -0,0 +1,27 @@ +'use strict' + +const { request, errors } = require('../../..') + +const acceptableCodes = [ + 'ERR_INVALID_ARG_TYPE' +] + +async function fuzz (netServer, results, buf) { + const headers = { buf: buf.toString() } + results.body = headers + try { + const data = await request(`http://localhost:${netServer.address().port}`, { headers }) + data.body.destroy().on('error', () => {}) + } catch (err) { + results.err = err + // Handle any undici errors + if (Object.values(errors).some(undiciError => err instanceof undiciError)) { + // Okay error + } else if (!acceptableCodes.includes(err.code)) { + console.log(`=== Headers: ${JSON.stringify(headers)} ===`) + throw err + } + } +} + +module.exports = fuzz diff --git a/test/fuzzing/client/client-fuzz-options.js b/test/fuzzing/client/client-fuzz-options.js new file mode 100644 index 0000000..5be81b6 --- /dev/null +++ b/test/fuzzing/client/client-fuzz-options.js @@ -0,0 +1,38 @@ +'use strict' + +const { request, errors } = require('../../..') + +const acceptableCodes = [ + 'ERR_INVALID_URL', + // These are included because '\\ABC' is interpreted as a Windows UNC path and can cause these errors. + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED' + // ---- +] + +async function fuzz (netServer, results, buf) { + const optionKeys = ['body', 'path', 'method', 'opaque', 'upgrade', buf] + const options = {} + for (const optionKey of optionKeys) { + if (Math.random() < 0.5) { + options[optionKey] = buf.toString() + } + } + results.options = options + try { + const data = await request(`http://localhost:${netServer.address().port}`, options) + data.body.destroy().on('error', () => {}) + } catch (err) { + results.err = err + // Handle any undici errors + if (Object.values(errors).some(undiciError => err instanceof undiciError)) { + // Okay error + } else if (!acceptableCodes.includes(err.code)) { + console.log(`=== Options: ${JSON.stringify(options)} ===`) + throw err + } + } +} + +module.exports = fuzz diff --git a/test/fuzzing/client/index.js b/test/fuzzing/client/index.js new file mode 100644 index 0000000..dac3d98 --- /dev/null +++ b/test/fuzzing/client/index.js @@ -0,0 +1,7 @@ +'use strict' + +module.exports = { + clientFuzzBody: require('./client-fuzz-body'), + clientFuzzHeaders: require('./client-fuzz-headers'), + clientFuzzOptions: require('./client-fuzz-options') +} diff --git a/test/fuzzing/fuzz.js b/test/fuzzing/fuzz.js new file mode 100644 index 0000000..c268178 --- /dev/null +++ b/test/fuzzing/fuzz.js @@ -0,0 +1,66 @@ +'use strict' + +const net = require('net') +const fs = require('fs/promises') +const path = require('path') +const serverFuzzFnMap = require('./server') +const clientFuzzFnMap = require('./client') + +const port = process.env.PORT || 0 +const timeout = parseInt(process.env.TIMEOUT, 10) || 300_000 // 5 minutes by default + +const netServer = net.createServer((socket) => { + socket.on('data', (data) => { + // Select server fuzz fn + const serverFuzzFns = Object.values(serverFuzzFnMap) + const serverFuzzFn = serverFuzzFns[Math.floor(Math.random() * serverFuzzFns.length)] + + serverFuzzFn(socket, data) + }) +}) +const waitForNetServer = netServer.listen(port) + +// Set script to exit gracefully after a set period of time. +const timer = setTimeout(() => { + process.kill(process.pid, 'SIGINT') +}, timeout) + +async function writeResults (resultsPath, data) { + try { + await fs.writeFile(resultsPath, JSON.stringify(data, null, 2)) + console.log(`=== Written results to ${resultsPath} ===`) + } catch (err) { + console.log(`=== Unable to write results to ${resultsPath}`, err, '===') + } +} + +async function fuzz (buf) { + // Wait for net server to be ready + await waitForNetServer + + // Select client fuzz fn based on the buf input + await Promise.all( + Object.entries(clientFuzzFnMap).map(async ([clientFuzzFnName, clientFuzzFn]) => { + const results = {} + try { + await clientFuzzFn(netServer, results, buf) + } catch (err) { + clearTimeout(timer) + const output = { clientFuzzFnName, buf: { raw: buf, string: buf.toString() }, raw: JSON.stringify({ clientFuzzFnName, buf: { raw: buf, string: buf.toString() }, err, ...results }), err, ...results } + + console.log(`=== Failed fuzz ${clientFuzzFnName} with input '${buf}' ===`) + console.log('=== Fuzz results start ===') + console.log(output) + console.log('=== Fuzz results end ===') + + await writeResults(path.resolve(`fuzz-results-${Date.now()}.json`), output) + + throw err + } + }) + ) +} + +module.exports = { + fuzz +} diff --git a/test/fuzzing/server/index.js b/test/fuzzing/server/index.js new file mode 100644 index 0000000..4bef554 --- /dev/null +++ b/test/fuzzing/server/index.js @@ -0,0 +1,6 @@ +'use strict' + +module.exports = { + splitData: require('./server-fuzz-split-data'), + appendData: require('./server-fuzz-append-data') +} diff --git a/test/fuzzing/server/server-fuzz-append-data.js b/test/fuzzing/server/server-fuzz-append-data.js new file mode 100644 index 0000000..8ef6c45 --- /dev/null +++ b/test/fuzzing/server/server-fuzz-append-data.js @@ -0,0 +1,7 @@ +'use strict' + +function appendData (socket, data) { + socket.end('HTTP/1.1 200 OK' + data) +} + +module.exports = appendData diff --git a/test/fuzzing/server/server-fuzz-split-data.js b/test/fuzzing/server/server-fuzz-split-data.js new file mode 100644 index 0000000..5e057dc --- /dev/null +++ b/test/fuzzing/server/server-fuzz-split-data.js @@ -0,0 +1,17 @@ +'use strict' + +function splitData (socket, data) { + const lines = [ + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + data + ] + for (const line of lines.join('\r\n').split(data)) { + socket.write(line) + } + socket.end() +} + +module.exports = splitData diff --git a/test/gc.js b/test/gc.js new file mode 100644 index 0000000..c1ceecf --- /dev/null +++ b/test/gc.js @@ -0,0 +1,98 @@ +'use strict' +/* global WeakRef, FinalizationRegistry */ + +const { test } = require('tap') +const { createServer } = require('net') +const { Client, Pool } = require('..') + +const SKIP = typeof WeakRef === 'undefined' || typeof FinalizationRegistry === 'undefined' + +setInterval(() => { + global.gc() +}, 100).unref() + +test('gc should collect the client if, and only if, there are no active sockets', { skip: SKIP }, t => { + t.plan(4) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + let weakRef + let disconnected = false + + const registry = new FinalizationRegistry((data) => { + t.equal(data, 'test') + t.equal(disconnected, true) + t.equal(weakRef.deref(), undefined) + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeoutThreshold: 100 + }) + client.once('disconnect', () => { + disconnected = true + }) + + weakRef = new WeakRef(client) + registry.register(client, 'test') + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.resume() + }) + }) +}) + +test('gc should collect the pool if, and only if, there are no active sockets', { skip: SKIP }, t => { + t.plan(4) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + t.teardown(server.close.bind(server)) + + let weakRef + let disconnected = false + + const registry = new FinalizationRegistry((data) => { + t.equal(data, 'test') + t.equal(disconnected, true) + t.equal(weakRef.deref(), undefined) + }) + + server.listen(0, () => { + const pool = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + keepAliveTimeoutThreshold: 500 + }) + + pool.once('disconnect', () => { + disconnected = true + }) + + weakRef = new WeakRef(pool) + registry.register(pool, 'test') + + pool.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.error(err) + body.resume() + }) + }) +}) diff --git a/test/get-head-body.js b/test/get-head-body.js new file mode 100644 index 0000000..3e86b13 --- /dev/null +++ b/test/get-head-body.js @@ -0,0 +1,184 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const { kConnect } = require('../lib/core/symbols') +const { kBusy } = require('../lib/core/symbols') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') + +test('GET and HEAD with body should reset connection', (t) => { + t.plan(8 + 2) + + const server = createServer((req, res) => { + res.end('asd') + }) + + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.on('disconnect', () => { + t.pass() + }) + + client.request({ + path: '/', + body: 'asd', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + const emptyBody = new Readable({ + read () {} + }) + emptyBody.push(null) + client.request({ + path: '/', + body: emptyBody, + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + body: new Readable({ + read () { + this.push(null) + } + }), + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }), + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + body: [], + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + body: wrapWithAsyncIterable(new Readable({ + read () { + this.push(null) + } + })), + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + + client.request({ + path: '/', + body: wrapWithAsyncIterable(new Readable({ + read () { + this.push('asd') + this.push(null) + } + })), + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + }) +}) + +// TODO: Avoid external dependency. +// test('GET with body should work when target parses body as request', (t) => { +// t.plan(4) + +// // This URL will send double responses when receiving a +// // GET request with body. +// const client = new Client('http://feeds.bbci.co.uk') +// t.teardown(client.close.bind(client)) + +// client.request({ method: 'GET', path: '/news/rss.xml', body: 'asd' }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 200) +// data.body.resume() +// }) +// client.request({ method: 'GET', path: '/news/rss.xml', body: 'asd' }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 200) +// data.body.resume() +// }) +// }) + +test('HEAD should reset connection', (t) => { + t.plan(8) + + const server = createServer((req, res) => { + res.end('asd') + }) + + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.once('disconnect', () => { + t.pass() + }) + + client.request({ + path: '/', + method: 'HEAD' + }, (err, data) => { + t.error(err) + data.body.resume() + }) + t.equal(client[kBusy], true) + + client.request({ + path: '/', + method: 'HEAD' + }, (err, data) => { + t.error(err) + data.body.resume() + client.once('disconnect', () => { + client[kConnect](() => { + client.request({ + path: '/', + method: 'HEAD' + }, (err, data) => { + t.error(err) + data.body.resume() + data.body.on('end', () => { + t.pass() + }) + }) + t.equal(client[kBusy], true) + }) + }) + }) + t.equal(client[kBusy], true) + }) +}) diff --git a/test/headers-as-array.js b/test/headers-as-array.js new file mode 100644 index 0000000..fd8bb5d --- /dev/null +++ b/test/headers-as-array.js @@ -0,0 +1,131 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') + +test('handle headers as array', (t) => { + t.plan(1) + const headers = ['a', '1', 'b', '2', 'c', '3'] + + const server = createServer((req, res) => { + t.match(req.headers, { a: '1', b: '2', c: '3' }) + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, () => {}) + }) +}) + +test('handle multi-valued headers as array', (t) => { + t.plan(1) + const headers = ['a', '1', 'b', '2', 'c', '3', 'd', '4', 'd', '5'] + + const server = createServer((req, res) => { + t.match(req.headers, { a: '1', b: '2', c: '3', d: '4, 5' }) + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, () => {}) + }) +}) + +test('handle headers with array', (t) => { + t.plan(1) + const headers = { a: '1', b: '2', c: '3', d: ['4'] } + + const server = createServer((req, res) => { + t.match(req.headers, { a: '1', b: '2', c: '3', d: '4' }) + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, () => {}) + }) +}) + +test('handle multi-valued headers', (t) => { + t.plan(1) + const headers = { a: '1', b: '2', c: '3', d: ['4', '5'] } + + const server = createServer((req, res) => { + t.match(req.headers, { a: '1', b: '2', c: '3', d: '4, 5' }) + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, () => {}) + }) +}) + +test('fail if headers array is odd', (t) => { + t.plan(2) + const headers = ['a', '1', 'b', '2', 'c', '3', 'd'] + + const server = createServer((req, res) => { res.end() }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, (err) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'headers array must be even') + }) + }) +}) + +test('fail if headers is not an object or an array', (t) => { + t.plan(2) + const headers = 'not an object or an array' + + const server = createServer((req, res) => { res.end() }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers + }, (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + t.equal(err.message, 'headers must be an object or an array') + }) + }) +}) diff --git a/test/headers-crlf.js b/test/headers-crlf.js new file mode 100644 index 0000000..b24fd39 --- /dev/null +++ b/test/headers-crlf.js @@ -0,0 +1,36 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') + +test('CRLF Injection in Nodejs ‘undici’ via host', (t) => { + t.plan(1) + + const server = createServer(async (req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const unsanitizedContentTypeInput = '12 \r\n\r\naaa:aaa' + + try { + const { body } = await client.request({ + path: '/', + method: 'POST', + headers: { + 'content-type': 'application/json', + host: unsanitizedContentTypeInput + }, + body: 'asd' + }) + await body.dump() + } catch (err) { + t.same(err.code, 'UND_ERR_INVALID_ARG') + } + }) +}) diff --git a/test/http-100.js b/test/http-100.js new file mode 100644 index 0000000..1662a8d --- /dev/null +++ b/test/http-100.js @@ -0,0 +1,141 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const net = require('net') + +test('ignore informational response', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.writeProcessing() + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'POST', + body: 'hello' + }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('error 103 body', (t) => { + t.plan(2) + + const server = net.createServer((socket) => { + socket.write('HTTP/1.1 103 Early Hints\r\n') + socket.write('Content-Length: 1\r\n') + socket.write('\r\n') + socket.write('a\r\n') + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err.code, 'HPE_INVALID_CONSTANT') + }) + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('error 100 body', (t) => { + t.plan(2) + + const server = net.createServer((socket) => { + socket.write('HTTP/1.1 100 Early Hints\r\n') + socket.write('\r\n') + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err.message, 'bad response') + }) + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('error 101 upgrade', (t) => { + t.plan(2) + + const server = net.createServer((socket) => { + socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: example/1\r\nConnection: Upgrade\r\n') + socket.write('\r\n') + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err.message, 'bad upgrade') + }) + client.on('disconnect', () => { + t.pass() + }) + }) +}) + +test('1xx response without timeouts', t => { + t.plan(2) + + const server = createServer((req, res) => { + res.writeProcessing() + setTimeout(() => req.pipe(res), 2000) + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 0 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'POST', + body: 'hello' + }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) diff --git a/test/http-req-destroy.js b/test/http-req-destroy.js new file mode 100644 index 0000000..29ec98e --- /dev/null +++ b/test/http-req-destroy.js @@ -0,0 +1,69 @@ +'use strict' + +const { test } = require('tap') +const undici = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const { maybeWrapStream, consts } = require('./utils/async-iterators') + +function doNotKillReqSocket (bodyType) { + test(`do not kill req socket ${bodyType}`, (t) => { + t.plan(3) + + const server1 = createServer((req, res) => { + const client = new undici.Client(`http://localhost:${server2.address().port}`) + t.teardown(client.close.bind(client)) + client.request({ + path: '/', + method: 'POST', + body: req + }, (err, response) => { + t.error(err) + setTimeout(() => { + response.body.on('data', buf => { + res.write(buf) + setTimeout(() => { + res.end() + }, 100) + }) + }, 100) + }) + }) + t.teardown(server1.close.bind(server1)) + + const server2 = createServer((req, res) => { + setTimeout(() => { + req.pipe(res) + }, 100) + }) + t.teardown(server2.close.bind(server2)) + + server1.listen(0, () => { + const client = new undici.Client(`http://localhost:${server1.address().port}`) + t.teardown(client.close.bind(client)) + + const r = new Readable({ read () {} }) + r.push('hello') + client.request({ + path: '/', + method: 'POST', + body: maybeWrapStream(r, bodyType) + }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + r.push(null) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + server2.listen(0) + }) +} + +doNotKillReqSocket(consts.STREAM) +doNotKillReqSocket(consts.ASYNC_ITERATOR) diff --git a/test/http2-alpn.js b/test/http2-alpn.js new file mode 100644 index 0000000..04b8cb6 --- /dev/null +++ b/test/http2-alpn.js @@ -0,0 +1,277 @@ +'use strict' + +const https = require('node:https') +const { once } = require('node:events') +const { createSecureServer } = require('node:http2') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const { test } = require('tap') + +const { Client } = require('..') + +// get the crypto fixtures +const key = readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8') +const cert = readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') +const ca = readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + +test('Should upgrade to HTTP/2 when HTTPS/1 is available for GET', async (t) => { + t.plan(10) + + const body = [] + const httpsBody = [] + + // create the server and server stream handler + const server = createSecureServer( + { + key, + cert, + allowHTTP1: true + }, + (req, res) => { + const { socket: { alpnProtocol } } = req.httpVersion === '2.0' ? req.stream.session : req + + // handle http/1 requests + res.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'x-custom-request-header': req.headers['x-custom-request-header'] || '', + 'x-custom-response-header': `using ${req.httpVersion}` + }) + res.end(JSON.stringify({ + alpnProtocol, + httpVersion: req.httpVersion + })) + } + ) + + server.listen(0) + await once(server, 'listening') + + // close the server on teardown + t.teardown(server.close.bind(server)) + + // set the port + const port = server.address().port + + // test undici against http/2 + const client = new Client(`https://localhost:${port}`, { + connect: { + ca, + servername: 'agent1' + }, + allowH2: true + }) + + // close the client on teardown + t.teardown(client.close.bind(client)) + + // make an undici request using where it wants http/2 + const response = await client.request({ + path: '/', + method: 'GET', + headers: { + 'x-custom-request-header': 'want 2.0' + } + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'application/json; charset=utf-8') + t.equal(response.headers['x-custom-request-header'], 'want 2.0') + t.equal(response.headers['x-custom-response-header'], 'using 2.0') + t.equal(Buffer.concat(body).toString('utf8'), JSON.stringify({ + alpnProtocol: 'h2', + httpVersion: '2.0' + })) + + // make an https request for http/1 to confirm undici is using http/2 + const httpsOptions = { + ca, + servername: 'agent1', + headers: { + 'x-custom-request-header': 'want 1.1' + } + } + + const httpsResponse = await new Promise((resolve, reject) => { + const httpsRequest = https.get(`https://localhost:${port}/`, httpsOptions, (res) => { + res.on('data', (chunk) => { + httpsBody.push(chunk) + }) + + res.on('end', () => { + resolve(res) + }) + }).on('error', (err) => { + reject(err) + }) + + t.teardown(httpsRequest.destroy.bind(httpsRequest)) + }) + + t.equal(httpsResponse.statusCode, 200) + t.equal(httpsResponse.headers['content-type'], 'application/json; charset=utf-8') + t.equal(httpsResponse.headers['x-custom-request-header'], 'want 1.1') + t.equal(httpsResponse.headers['x-custom-response-header'], 'using 1.1') + t.equal(Buffer.concat(httpsBody).toString('utf8'), JSON.stringify({ + alpnProtocol: false, + httpVersion: '1.1' + })) +}) + +test('Should upgrade to HTTP/2 when HTTPS/1 is available for POST', async (t) => { + t.plan(15) + + const requestChunks = [] + const responseBody = [] + + const httpsRequestChunks = [] + const httpsResponseBody = [] + + const expectedBody = 'hello' + const buf = Buffer.from(expectedBody) + const body = new ArrayBuffer(buf.byteLength) + + buf.copy(new Uint8Array(body)) + + // create the server and server stream handler + const server = createSecureServer( + { + key, + cert, + allowHTTP1: true + }, + (req, res) => { + // use the stream handler for http2 + if (req.httpVersion === '2.0') { + return + } + + const { socket: { alpnProtocol } } = req + + req.on('data', (chunk) => { + httpsRequestChunks.push(chunk) + }) + + req.on('end', () => { + // handle http/1 requests + res.writeHead(201, { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-request-header': req.headers['x-custom-request-header'] || '', + 'x-custom-alpn-protocol': alpnProtocol + }) + res.end('hello http/1!') + }) + } + ) + + server.on('stream', (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + const { socket: { alpnProtocol } } = stream.session + + stream.on('data', (chunk) => { + requestChunks.push(chunk) + }) + + stream.respond({ + ':status': 201, + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-request-header': headers['x-custom-request-header'] || '', + 'x-custom-alpn-protocol': alpnProtocol + }) + + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + // close the server on teardown + t.teardown(server.close.bind(server)) + + // set the port + const port = server.address().port + + // test undici against http/2 + const client = new Client(`https://localhost:${port}`, { + connect: { + ca, + servername: 'agent1' + }, + allowH2: true + }) + + // close the client on teardown + t.teardown(client.close.bind(client)) + + // make an undici request using where it wants http/2 + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'x-custom-request-header': 'want 2.0' + }, + body + }) + + response.body.on('data', (chunk) => { + responseBody.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 201) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-request-header'], 'want 2.0') + t.equal(response.headers['x-custom-alpn-protocol'], 'h2') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + + // make an https request for http/1 to confirm undici is using http/2 + const httpsOptions = { + ca, + servername: 'agent1', + method: 'POST', + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': Buffer.byteLength(body), + 'x-custom-request-header': 'want 1.1' + } + } + + const httpsResponse = await new Promise((resolve, reject) => { + const httpsRequest = https.request(`https://localhost:${port}/`, httpsOptions, (res) => { + res.on('data', (chunk) => { + httpsResponseBody.push(chunk) + }) + + res.on('end', () => { + resolve(res) + }) + }).on('error', (err) => { + reject(err) + }) + + httpsRequest.on('error', (err) => { + reject(err) + }) + + httpsRequest.write(Buffer.from(body)) + + t.teardown(httpsRequest.destroy.bind(httpsRequest)) + }) + + t.equal(httpsResponse.statusCode, 201) + t.equal(httpsResponse.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(httpsResponse.headers['x-custom-request-header'], 'want 1.1') + t.equal(httpsResponse.headers['x-custom-alpn-protocol'], 'false') + t.equal(Buffer.concat(httpsResponseBody).toString('utf-8'), 'hello http/1!') + t.equal(Buffer.concat(httpsRequestChunks).toString('utf-8'), expectedBody) +}) diff --git a/test/http2.js b/test/http2.js new file mode 100644 index 0000000..71b7749 --- /dev/null +++ b/test/http2.js @@ -0,0 +1,1191 @@ +'use strict' + +const { createSecureServer } = require('node:http2') +const { createReadStream, readFileSync } = require('node:fs') +const { once } = require('node:events') +const { Blob } = require('node:buffer') +const { Writable, pipeline, PassThrough, Readable } = require('node:stream') + +const { test, plan } = require('tap') +const { gte } = require('semver') +const pem = require('https-pem') + +const { Client, Agent } = require('..') + +const isGreaterThanv20 = gte(process.version.slice(1), '20.0.0') +// NOTE: node versions <16.14.1 have a bug which changes the order of pseudo-headers +// https://github.com/nodejs/node/pull/41735 +const hasPseudoHeadersOrderFix = gte(process.version.slice(1), '16.14.1') + +plan(23) + +test('Should support H2 connection', async t => { + const body = [] + const server = createSecureServer(pem) + + server.on('stream', (stream, headers, _flags, rawHeaders) => { + t.equal(headers['x-my-header'], 'foo') + t.equal(headers[':method'], 'GET') + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(6) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), 'hello h2!') +}) + +test('Should support H2 connection(multiple requests)', async t => { + const server = createSecureServer(pem) + + server.on('stream', async (stream, headers, _flags, rawHeaders) => { + t.equal(headers['x-my-header'], 'foo') + t.equal(headers[':method'], 'POST') + const reqData = [] + stream.on('data', chunk => reqData.push(chunk.toString())) + await once(stream, 'end') + const reqBody = reqData.join('') + t.equal(reqBody.length > 0, true) + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end(`hello h2! ${reqBody}`) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(21) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + for (let i = 0; i < 3; i++) { + const sendBody = `seq ${i}` + const body = [] + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'x-my-header': 'foo' + }, + body: Readable.from(sendBody) + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), `hello h2! ${sendBody}`) + } +}) + +test('Should support H2 connection (headers as array)', async t => { + const body = [] + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + t.equal(headers['x-my-header'], 'foo') + t.equal(headers['x-my-drink'], 'coffee,tea') + t.equal(headers[':method'], 'GET') + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(7) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET', + headers: ['x-my-header', 'foo', 'x-my-drink', ['coffee', 'tea']] + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), 'hello h2!') +}) + +test('Should support H2 connection(POST Buffer)', async t => { + const server = createSecureServer({ ...pem, allowHTTP1: false }) + + server.on('stream', async (stream, headers, _flags, rawHeaders) => { + t.equal(headers[':method'], 'POST') + const reqData = [] + stream.on('data', chunk => reqData.push(chunk.toString())) + await once(stream, 'end') + t.equal(reqData.join(''), 'hello!') + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(6) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const sendBody = 'hello!' + const body = [] + const response = await client.request({ + path: '/', + method: 'POST', + body: sendBody + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), 'hello h2!') +}) + +test('Should support H2 GOAWAY (server-side)', async t => { + const body = [] + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + t.equal(headers['x-my-header'], 'foo') + t.equal(headers[':method'], 'GET') + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.on('session', session => { + setTimeout(() => { + session.goaway(204) + }, 1000) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(9) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), 'hello h2!') + + const [url, disconnectClient, err] = await once(client, 'disconnect') + + t.type(url, URL) + t.same(disconnectClient, [client]) + t.equal(err.message, 'HTTP/2: "GOAWAY" frame received with code 204') +}) + +test('Should throw if bad allowH2 has been pased', async t => { + try { + // eslint-disable-next-line + new Client('https://localhost:1000', { + allowH2: 'true' + }) + t.fail() + } catch (error) { + t.equal(error.message, 'allowH2 must be a valid boolean value') + } +}) + +test('Should throw if bad maxConcurrentStreams has been pased', async t => { + try { + // eslint-disable-next-line + new Client('https://localhost:1000', { + allowH2: true, + maxConcurrentStreams: {} + }) + t.fail() + } catch (error) { + t.equal( + error.message, + 'maxConcurrentStreams must be a possitive integer, greater than 0' + ) + } + + try { + // eslint-disable-next-line + new Client('https://localhost:1000', { + allowH2: true, + maxConcurrentStreams: -1 + }) + t.fail() + } catch (error) { + t.equal( + error.message, + 'maxConcurrentStreams must be a possitive integer, greater than 0' + ) + } +}) + +test( + 'Request should fail if allowH2 is false and server advertises h1 only', + { skip: isGreaterThanv20 }, + async t => { + const server = createSecureServer( + { + ...pem, + allowHTTP1: false, + ALPNProtocols: ['http/1.1'] + }, + (req, res) => { + t.fail('Should not create a valid h2 stream') + } + ) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + allowH2: false, + connect: { + rejectUnauthorized: false + } + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + + t.equal(response.statusCode, 403) + } +) + +test( + '[v20] Request should fail if allowH2 is false and server advertises h1 only', + { skip: !isGreaterThanv20 }, + async t => { + const server = createSecureServer( + { + ...pem, + allowHTTP1: false, + ALPNProtocols: ['http/1.1'] + }, + (req, res) => { + t.fail('Should not create a valid h2 stream') + } + ) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + allowH2: false, + connect: { + rejectUnauthorized: false + } + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + t.plan(2) + + try { + await client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + } catch (error) { + t.equal( + error.message, + 'Client network socket disconnected before secure TLS connection was established' + ) + t.equal(error.code, 'ECONNRESET') + } + } +) + +test('Should handle h2 continue', async t => { + const requestBody = [] + const server = createSecureServer(pem, () => {}) + const responseBody = [] + + server.on('checkContinue', (request, response) => { + t.equal(request.headers.expect, '100-continue') + t.equal(request.headers['x-my-header'], 'foo') + t.equal(request.headers[':method'], 'POST') + response.writeContinue() + + request.on('data', chunk => requestBody.push(chunk)) + + response.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'foo' + }) + response.end('hello h2!') + }) + + t.plan(7) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + expectContinue: true, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'x-my-header': 'foo' + }, + expectContinue: true + }) + + response.body.on('data', chunk => { + responseBody.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'foo') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') +}) + +test('Dispatcher#Stream', t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const bufs = [] + let requestBody = '' + + server.on('stream', async (stream, headers) => { + stream.setEncoding('utf-8') + stream.on('data', chunk => { + requestBody += chunk + }) + + stream.respond({ ':status': 200, 'x-custom': 'custom-header' }) + stream.end('hello h2!') + }) + + t.plan(4) + + server.listen(0, async () => { + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + await client.stream( + { path: '/', opaque: { bufs }, method: 'POST', body: expectedBody }, + ({ statusCode, headers, opaque: { bufs } }) => { + t.equal(statusCode, 200) + t.equal(headers['x-custom'], 'custom-header') + + return new Writable({ + write (chunk, _encoding, cb) { + bufs.push(chunk) + cb() + } + }) + } + ) + + t.equal(Buffer.concat(bufs).toString('utf-8'), 'hello h2!') + t.equal(requestBody, expectedBody) + }) +}) + +test('Dispatcher#Pipeline', t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const bufs = [] + let requestBody = '' + + server.on('stream', async (stream, headers) => { + stream.setEncoding('utf-8') + stream.on('data', chunk => { + requestBody += chunk + }) + + stream.respond({ ':status': 200, 'x-custom': 'custom-header' }) + stream.end('hello h2!') + }) + + t.plan(5) + + server.listen(0, () => { + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + pipeline( + new Readable({ + read () { + this.push(Buffer.from(expectedBody)) + this.push(null) + } + }), + client.pipeline( + { path: '/', method: 'POST', body: expectedBody }, + ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['x-custom'], 'custom-header') + + return pipeline(body, new PassThrough(), () => {}) + } + ), + new Writable({ + write (chunk, _, cb) { + bufs.push(chunk) + cb() + } + }), + err => { + t.error(err) + t.equal(Buffer.concat(bufs).toString('utf-8'), 'hello h2!') + t.equal(requestBody, expectedBody) + } + ) + }) +}) + +test('Dispatcher#Connect', t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + let requestBody = '' + + server.on('stream', async (stream, headers) => { + stream.setEncoding('utf-8') + stream.on('data', chunk => { + requestBody += chunk + }) + + stream.respond({ ':status': 200, 'x-custom': 'custom-header' }) + stream.end('hello h2!') + }) + + t.plan(6) + + server.listen(0, () => { + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + let result = '' + client.connect({ path: '/' }, (err, { socket }) => { + t.error(err) + socket.on('data', chunk => { + result += chunk + }) + socket.on('response', headers => { + t.equal(headers[':status'], 200) + t.equal(headers['x-custom'], 'custom-header') + t.notOk(socket.closed) + }) + + // We need to handle the error event although + // is not controlled by Undici, the fact that a session + // is destroyed and destroys subsequent streams, causes + // unhandled errors to surface if not handling this event. + socket.on('error', () => {}) + + socket.once('end', () => { + t.equal(requestBody, expectedBody) + t.equal(result, 'hello h2!') + }) + socket.end(expectedBody) + }) + }) +}) + +test('Dispatcher#Upgrade', t => { + const server = createSecureServer(pem) + + server.on('stream', async (stream, headers) => { + stream.end() + }) + + t.plan(1) + + server.listen(0, async () => { + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + try { + await client.upgrade({ path: '/' }) + } catch (error) { + t.equal(error.message, 'Upgrade not supported for H2') + } + }) +}) + +test('Dispatcher#destroy', async t => { + const promises = [] + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + setTimeout(stream.end.bind(stream), 1500) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(4) + t.teardown(server.close.bind(server)) + + promises.push( + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + ) + + promises.push( + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + ) + + promises.push( + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + ) + + promises.push( + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + ) + + await client.destroy() + + const results = await Promise.allSettled(promises) + + t.equal(results[0].status, 'rejected') + t.equal(results[1].status, 'rejected') + t.equal(results[2].status, 'rejected') + t.equal(results[3].status, 'rejected') +}) + +test('Should handle h2 request with body (string or buffer) - dispatch', t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const response = [] + const requestBody = [] + + server.on('stream', async (stream, headers) => { + stream.on('data', chunk => requestBody.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(7) + + server.listen(0, () => { + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + client.dispatch( + { + path: '/', + method: 'POST', + headers: { + 'x-my-header': 'foo', + 'content-type': 'text/plain' + }, + body: expectedBody + }, + { + onConnect () { + t.ok(true) + }, + onError (err) { + t.error(err) + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain; charset=utf-8') + t.equal(headers['x-custom-h2'], 'foo') + }, + onData (chunk) { + response.push(chunk) + }, + onBodySent (body) { + t.equal(body.toString('utf-8'), expectedBody) + }, + onComplete () { + t.equal(Buffer.concat(response).toString('utf-8'), 'hello h2!') + t.equal( + Buffer.concat(requestBody).toString('utf-8'), + 'hello from client!' + ) + } + } + ) + }) +}) + +test('Should handle h2 request with body (stream)', async t => { + const server = createSecureServer(pem) + const expectedBody = readFileSync(__filename, 'utf-8') + const stream = createReadStream(__filename) + const requestChunks = [] + const responseBody = [] + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'PUT') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + for await (const chunk of stream) { + requestChunks.push(chunk) + } + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'PUT', + headers: { + 'x-my-header': 'foo' + }, + body: stream + }) + + for await (const chunk of response.body) { + responseBody.push(chunk) + } + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'foo') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test('Should handle h2 request with body (iterable)', async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello' + const requestChunks = [] + const responseBody = [] + const iterableBody = { + [Symbol.iterator]: function * () { + const end = expectedBody.length - 1 + for (let i = 0; i < end + 1; i++) { + yield expectedBody[i] + } + + return expectedBody[end] + } + } + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'x-my-header': 'foo' + }, + body: iterableBody + }) + + response.body.on('data', chunk => { + responseBody.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'foo') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test('Should handle h2 request with body (Blob)', { skip: !Blob }, async t => { + const server = createSecureServer(pem) + const expectedBody = 'asd' + const requestChunks = [] + const responseBody = [] + const body = new Blob(['asd'], { + type: 'application/json' + }) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'x-my-header': 'foo' + }, + body + }) + + response.body.on('data', chunk => { + responseBody.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'foo') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test( + 'Should handle h2 request with body (Blob:ArrayBuffer)', + { skip: !Blob }, + async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello' + const requestChunks = [] + const responseBody = [] + const buf = Buffer.from(expectedBody) + const body = new ArrayBuffer(buf.byteLength) + + buf.copy(new Uint8Array(body)) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'POST', + headers: { + 'x-my-header': 'foo' + }, + body + }) + + response.body.on('data', chunk => { + responseBody.push(chunk) + }) + + await once(response.body, 'end') + + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'foo') + t.equal(Buffer.concat(responseBody).toString('utf-8'), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) + +test('Agent should support H2 connection', async t => { + const body = [] + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + t.equal(headers['x-my-header'], 'foo') + t.equal(headers[':method'], 'GET') + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Agent({ + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(6) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + origin: `https://localhost:${server.address().port}`, + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }) + + response.body.on('data', chunk => { + body.push(chunk) + }) + + await once(response.body, 'end') + t.equal(response.statusCode, 200) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.headers['x-custom-h2'], 'hello') + t.equal(Buffer.concat(body).toString('utf8'), 'hello h2!') +}) + +test( + 'Should provide pseudo-headers in proper order', + { skip: !hasPseudoHeadersOrderFix }, + async t => { + const server = createSecureServer(pem) + server.on('stream', (stream, _headers, _flags, rawHeaders) => { + t.same(rawHeaders, [ + ':authority', + `localhost:${server.address().port}`, + ':method', + 'GET', + ':path', + '/', + ':scheme', + 'https' + ]) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + ':status': 200 + }) + stream.end() + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET' + }) + + t.equal(response.statusCode, 200) + } +) + +test('The h2 pseudo-headers is not included in the headers', async t => { + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + stream.respond({ + ':status': 200 + }) + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.plan(2) + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await client.request({ + path: '/', + method: 'GET' + }) + + await response.body.text() + + t.equal(response.statusCode, 200) + t.equal(response.headers[':status'], undefined) +}) diff --git a/test/https.js b/test/https.js new file mode 100644 index 0000000..1ba492c --- /dev/null +++ b/test/https.js @@ -0,0 +1,74 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('https') +const pem = require('https-pem') + +test('https get with tls opts', (t) => { + t.plan(6) + + const server = createServer(pem, (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`https://localhost:${server.address().port}`, { + tls: { + rejectUnauthorized: false + } + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('https get with tls opts ip', (t) => { + t.plan(6) + + const server = createServer(pem, (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`https://127.0.0.1:${server.address().port}`, { + tls: { + rejectUnauthorized: false + } + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) diff --git a/test/imports/undici-import.ts b/test/imports/undici-import.ts new file mode 100644 index 0000000..fb7344e --- /dev/null +++ b/test/imports/undici-import.ts @@ -0,0 +1,5 @@ +import { request } from '../../' + +async function exampleCode() { + await request('http://localhost:3000/foo') +} diff --git a/test/inflight-and-close.js b/test/inflight-and-close.js new file mode 100644 index 0000000..49fbb10 --- /dev/null +++ b/test/inflight-and-close.js @@ -0,0 +1,31 @@ +'use strict' + +const t = require('tap') +const { request } = require('..') +const http = require('http') + +const server = http.createServer((req, res) => { + res.writeHead(200) + res.end('Response body') + res.socket.end() // Close the connection immediately with every response +}).listen(0, '127.0.0.1', function () { + const url = `http://127.0.0.1:${this.address().port}` + request(url) + .then(({ statusCode, headers, body }) => { + t.pass('first response') + body.resume() + body.on('close', function () { + t.pass('first body closed') + }) + return request(url) + .then(({ statusCode, headers, body }) => { + t.pass('second response') + body.resume() + body.on('close', function () { + server.close() + }) + }) + }).catch((err) => { + t.error(err) + }) +}) diff --git a/test/invalid-headers.js b/test/invalid-headers.js new file mode 100644 index 0000000..caf9e0a --- /dev/null +++ b/test/invalid-headers.js @@ -0,0 +1,108 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') + +test('invalid headers', (t) => { + t.plan(10) + + const client = new Client('http://localhost:3000') + t.teardown(client.destroy.bind(client)) + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 'asd' + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: 1 + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'transfer-encoding': 'chunked' + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + upgrade: 'asd' + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + connection: 'asd' + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'keep-alive': 'timeout=5' + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + foo: {} + } + }, (err, data) => { + t.type(err, errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + expect: '100-continue' + } + }, (err, data) => { + t.type(err, errors.NotSupportedError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + Expect: '100-continue' + } + }, (err, data) => { + t.type(err, errors.NotSupportedError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + expect: 'asd' + } + }, (err, data) => { + t.type(err, errors.NotSupportedError) + }) +}) diff --git a/test/issue-1670.js b/test/issue-1670.js new file mode 100644 index 0000000..c27bdb2 --- /dev/null +++ b/test/issue-1670.js @@ -0,0 +1,12 @@ +'use strict' + +const { test } = require('tap') +const { request } = require('..') + +test('https://github.com/mcollina/undici/issues/810', async (t) => { + const { body } = await request('https://api.github.com/user/emails') + + await body.text() + + t.end() +}) diff --git a/test/issue-1903.js b/test/issue-1903.js new file mode 100644 index 0000000..76ac81e --- /dev/null +++ b/test/issue-1903.js @@ -0,0 +1,78 @@ +'use strict' + +const { createServer } = require('http') +const { test } = require('tap') +const { request } = require('..') +const { nodeMajor } = require('../lib/core/util') + +function createPromise () { + const result = {} + result.promise = new Promise((resolve) => { + result.resolve = resolve + }) + return result +} + +test('should parse content-disposition consistently', { skip: nodeMajor >= 18 }, async (t) => { + t.plan(5) + + // create promise to allow server spinup in parallel + const spinup1 = createPromise() + const spinup2 = createPromise() + const spinup3 = createPromise() + + // variables to store content-disposition header + const header = [] + + const server = createServer((req, res) => { + res.writeHead(200, { + 'content-length': 2, + 'content-disposition': "attachment; filename='Ã¥r.pdf'" + }) + header.push("attachment; filename='Ã¥r.pdf'") + res.end('OK', spinup1.resolve) + }) + t.teardown(server.close.bind(server)) + server.listen(0, spinup1.resolve) + + const proxy1 = createServer(async (req, res) => { + const { statusCode, headers, body } = await request(`http://localhost:${server.address().port}`, { + method: 'GET' + }) + header.push(headers['content-disposition']) + delete headers['transfer-encoding'] + res.writeHead(statusCode, headers) + body.pipe(res) + }) + t.teardown(proxy1.close.bind(proxy1)) + proxy1.listen(0, spinup2.resolve) + + const proxy2 = createServer(async (req, res) => { + const { statusCode, headers, body } = await request(`http://localhost:${proxy1.address().port}`, { + method: 'GET' + }) + header.push(headers['content-disposition']) + delete headers['transfer-encoding'] + res.writeHead(statusCode, headers) + body.pipe(res) + }) + t.teardown(proxy2.close.bind(proxy2)) + proxy2.listen(0, spinup3.resolve) + + // wait until all server spinup + await Promise.all([spinup1.promise, spinup2.promise, spinup3.promise]) + + const { statusCode, headers, body } = await request(`http://localhost:${proxy2.address().port}`, { + method: 'GET' + }) + header.push(headers['content-disposition']) + t.equal(statusCode, 200) + t.equal(await body.text(), 'OK') + + // we check header + // must not be the same in first proxy + t.notSame(header[0], header[1]) + // chaining always the same value + t.equal(header[1], header[2]) + t.equal(header[2], header[3]) +}) diff --git a/test/issue-2065.js b/test/issue-2065.js new file mode 100644 index 0000000..cc288c4 --- /dev/null +++ b/test/issue-2065.js @@ -0,0 +1,71 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor, nodeMinor } = require('../lib/core/util') +const { createServer } = require('http') +const { once } = require('events') +const { createReadStream } = require('fs') +const { File, FormData, request } = require('..') + +if (nodeMajor < 16 || (nodeMajor === 16 && nodeMinor < 8)) { + skip('FormData is not available in node < v16.8.0') + process.exit() +} + +test('undici.request with a FormData body should set content-length header', async (t) => { + const server = createServer((req, res) => { + t.ok(req.headers['content-length']) + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const body = new FormData() + body.set('file', new File(['abc'], 'abc.txt')) + + await request(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) +}) + +test('undici.request with a FormData stream value should set transfer-encoding header', async (t) => { + const server = createServer((req, res) => { + t.equal(req.headers['transfer-encoding'], 'chunked') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + class BlobFromStream { + #stream + #type + constructor (stream, type) { + this.#stream = stream + this.#type = type + } + + stream () { + return this.#stream + } + + get type () { + return this.#type + } + + get [Symbol.toStringTag] () { + return 'Blob' + } + } + + const body = new FormData() + const fileReadable = createReadStream(__filename) + body.set('file', new BlobFromStream(fileReadable, '.js'), 'streamfile') + + await request(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) +}) diff --git a/test/issue-2078.js b/test/issue-2078.js new file mode 100644 index 0000000..d3aa868 --- /dev/null +++ b/test/issue-2078.js @@ -0,0 +1,30 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor, nodeMinor } = require('../lib/core/util') +const { MockAgent, getGlobalDispatcher, setGlobalDispatcher, fetch } = require('..') + +if (nodeMajor < 16 || (nodeMajor === 16 && nodeMinor < 8)) { + skip('fetch is not supported in node < v16.8.0') + process.exit() +} + +test('MockPool.reply headers are an object, not an array - issue #2078', async (t) => { + const global = getGlobalDispatcher() + const mockAgent = new MockAgent() + const mockPool = mockAgent.get('http://localhost') + + t.teardown(() => setGlobalDispatcher(global)) + setGlobalDispatcher(mockAgent) + + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply((options) => { + t.ok(!Array.isArray(options.headers)) + + return { statusCode: 200 } + }) + + await t.resolves(fetch('http://localhost/foo')) +}) diff --git a/test/issue-2349.js b/test/issue-2349.js new file mode 100644 index 0000000..a82bb74 --- /dev/null +++ b/test/issue-2349.js @@ -0,0 +1,53 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor } = require('../lib/core/util') +const { Writable } = require('stream') +const { MockAgent, errors, stream } = require('..') + +if (nodeMajor < 16) { + skip('only for node 16') + process.exit(0) +} + +test('stream() does not fail after request has been aborted', async (t) => { + t.plan(1) + + const mockAgent = new MockAgent() + + mockAgent.disableNetConnect() + mockAgent + .get('http://localhost:3333') + .intercept({ + path: '/' + }) + .reply(200, 'ok') + .delay(10) + + const parts = [] + const ac = new AbortController() + + setTimeout(() => ac.abort('nevermind'), 5) + + try { + await stream( + 'http://localhost:3333/', + { + opaque: { parts }, + signal: ac.signal, + dispatcher: mockAgent + }, + ({ opaque: { parts } }) => { + return new Writable({ + write (chunk, _encoding, callback) { + parts.push(chunk) + callback() + } + }) + } + ) + } catch (error) { + console.log(error) + t.equal(error instanceof errors.RequestAbortedError, true) + } +}) diff --git a/test/issue-803.js b/test/issue-803.js new file mode 100644 index 0000000..70f64cc --- /dev/null +++ b/test/issue-803.js @@ -0,0 +1,47 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const EE = require('events') + +test('https://github.com/nodejs/undici/issues/803', (t) => { + t.plan(2) + + const SIZE = 5900373096 + + const server = createServer(async (req, res) => { + res.setHeader('content-length', SIZE) + let pos = 0 + while (pos < SIZE) { + const len = Math.min(SIZE - pos, 65536) + if (!res.write(Buffer.allocUnsafe(len))) { + await EE.once(res, 'drain') + } + pos += len + } + + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + + let pos = 0 + data.body.on('data', (buf) => { + pos += buf.length + }) + data.body.on('end', () => { + t.equal(pos, SIZE) + }) + }) + }) +}) diff --git a/test/issue-810.js b/test/issue-810.js new file mode 100644 index 0000000..226a5aa --- /dev/null +++ b/test/issue-810.js @@ -0,0 +1,135 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const net = require('net') + +test('https://github.com/mcollina/undici/issues/810', (t) => { + t.plan(3) + + let x = 0 + const server = net.createServer(socket => { + if (x++ === 0) { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 1\r\n\r\n') + socket.write('11111\r\n') + } else { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n\r\n') + socket.write('\r\n') + } + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { pipelining: 2 }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + // t.fail() FIX: Should fail. + t.pass() + }).on('error', err => ( + t.type(err, errors.HTTPParserError) + )) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.type(err, errors.HTTPParserError) + }) + }) +}) + +test('https://github.com/mcollina/undici/issues/810 no pipelining', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 1\r\n\r\n') + socket.write('11111\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + // t.fail() FIX: Should fail. + t.pass() + }) + }) + }) +}) + +test('https://github.com/mcollina/undici/issues/810 pipelining', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 1\r\n\r\n') + socket.write('11111\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { pipelining: true }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + // t.fail() FIX: Should fail. + t.pass() + }) + }) + }) +}) + +test('https://github.com/mcollina/undici/issues/810 pipelining 2', (t) => { + t.plan(4) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 1\r\n\r\n') + socket.write('11111\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { pipelining: true }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.resume().on('end', () => { + // t.fail() FIX: Should fail. + t.pass() + }) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.equal(err.code, 'HPE_INVALID_CONSTANT') + t.type(err, errors.HTTPParserError) + }) + }) +}) diff --git a/test/jest/instanceof-error.test.js b/test/jest/instanceof-error.test.js new file mode 100644 index 0000000..8bb36d2 --- /dev/null +++ b/test/jest/instanceof-error.test.js @@ -0,0 +1,44 @@ +'use strict' + +const { createServer } = require('http') +const { once } = require('events') + +/* global expect, it, jest, AbortController */ + +// https://github.com/facebook/jest/issues/11607#issuecomment-899068995 +jest.useRealTimers() + +const runIf = (condition) => condition ? it : it.skip +const nodeMajor = Number(process.versions.node.split('.', 1)[0]) + +runIf(nodeMajor >= 16)('isErrorLike sanity check', () => { + const { isErrorLike } = require('../../lib/fetch/util') + const { DOMException } = require('../../lib/fetch/constants') + const error = new DOMException('') + + // https://github.com/facebook/jest/issues/2549 + expect(error instanceof Error).toBeFalsy() + expect(isErrorLike(error)).toBeTruthy() +}) + +runIf(nodeMajor >= 16)('Real use-case', async () => { + const { fetch } = require('../..') + + const ac = new AbortController() + ac.abort() + + const server = createServer((req, res) => { + res.end() + }).listen(0) + + await once(server, 'listening') + + const promise = fetch(`https://localhost:${server.address().port}`, { + signal: ac.signal + }) + + await expect(promise).rejects.toThrowError(/^Th(e|is) operation was aborted\.?$/) + + server.close() + await once(server, 'close') +}) diff --git a/test/jest/interceptor.test.js b/test/jest/interceptor.test.js new file mode 100644 index 0000000..73d70b7 --- /dev/null +++ b/test/jest/interceptor.test.js @@ -0,0 +1,197 @@ +'use strict' + +const { createServer } = require('http') +const { Agent, request } = require('../../index') +const DecoratorHandler = require('../../lib/handler/DecoratorHandler') +/* global expect */ + +const defaultOpts = { keepAliveTimeout: 10, keepAliveMaxTimeout: 10 } + +describe('interceptors', () => { + let server + beforeEach(async () => { + server = createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + await new Promise((resolve) => { server.listen(0, resolve) }) + }) + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)) + }) + + test('interceptors are applied on client from an agent', async () => { + const interceptors = [] + const buildInterceptor = dispatch => { + const interceptorContext = { requestCount: 0 } + interceptors.push(interceptorContext) + return (opts, handler) => { + interceptorContext.requestCount++ + return dispatch(opts, handler) + } + } + + const opts = { interceptors: { Client: [buildInterceptor] }, ...defaultOpts } + const agent = new Agent(opts) + const origin = new URL(`http://localhost:${server.address().port}`) + await Promise.all([ + request(origin, { dispatcher: agent }), + request(origin, { dispatcher: agent }) + ]) + + // Assert that the requests are run on different interceptors (different Clients) + const requestCounts = interceptors.map(x => x.requestCount) + expect(requestCounts).toEqual([1, 1]) + }) + + test('interceptors are applied in the correct order', async () => { + const setHeaderInterceptor = (dispatch) => { + return (opts, handler) => { + opts.headers.push('foo', 'bar') + return dispatch(opts, handler) + } + } + + const assertHeaderInterceptor = (dispatch) => { + return (opts, handler) => { + expect(opts.headers).toEqual(['foo', 'bar']) + return dispatch(opts, handler) + } + } + + const opts = { interceptors: { Pool: [setHeaderInterceptor, assertHeaderInterceptor] }, ...defaultOpts } + const agent = new Agent(opts) + const origin = new URL(`http://localhost:${server.address().port}`) + await request(origin, { dispatcher: agent, headers: [] }) + }) + + test('interceptors handlers are called in reverse order', async () => { + const clearResponseHeadersInterceptor = (dispatch) => { + return (opts, handler) => { + class ResultInterceptor extends DecoratorHandler { + onHeaders (statusCode, headers, resume) { + return super.onHeaders(statusCode, [], resume) + } + } + + return dispatch(opts, new ResultInterceptor(handler)) + } + } + + const assertHeaderInterceptor = (dispatch) => { + return (opts, handler) => { + class ResultInterceptor extends DecoratorHandler { + onHeaders (statusCode, headers, resume) { + expect(headers).toEqual([]) + return super.onHeaders(statusCode, headers, resume) + } + } + + return dispatch(opts, new ResultInterceptor(handler)) + } + } + + const opts = { interceptors: { Agent: [assertHeaderInterceptor, clearResponseHeadersInterceptor] }, ...defaultOpts } + const agent = new Agent(opts) + const origin = new URL(`http://localhost:${server.address().port}`) + await request(origin, { dispatcher: agent, headers: [] }) + }) +}) + +describe('interceptors with NtlmRequestHandler', () => { + class FakeNtlmRequestHandler { + constructor (dispatch, opts, handler) { + this.dispatch = dispatch + this.opts = opts + this.handler = handler + this.requestCount = 0 + } + + onConnect (...args) { + return this.handler.onConnect(...args) + } + + onError (...args) { + return this.handler.onError(...args) + } + + onUpgrade (...args) { + return this.handler.onUpgrade(...args) + } + + onHeaders (statusCode, headers, resume, statusText) { + this.requestCount++ + if (this.requestCount < 2) { + // Do nothing + } else { + return this.handler.onHeaders(statusCode, headers, resume, statusText) + } + } + + onData (...args) { + if (this.requestCount < 2) { + // Do nothing + } else { + return this.handler.onData(...args) + } + } + + onComplete (...args) { + if (this.requestCount < 2) { + this.dispatch(this.opts, this) + } else { + return this.handler.onComplete(...args) + } + } + + onBodySent (...args) { + if (this.requestCount < 2) { + // Do nothing + } else { + return this.handler.onBodySent(...args) + } + } + } + let server + + beforeEach(async () => { + // This Test is important because NTLM and Negotiate require several + // http requests in sequence to run on the same keepAlive socket + + const socketRequestCountSymbol = Symbol('Socket Request Count') + server = createServer((req, res) => { + if (req.socket[socketRequestCountSymbol] === undefined) { + req.socket[socketRequestCountSymbol] = 0 + } + req.socket[socketRequestCountSymbol]++ + res.setHeader('Content-Type', 'text/plain') + + // Simulate NTLM/Negotiate logic, by returning 200 + // on the second request of each socket + if (req.socket[socketRequestCountSymbol] >= 2) { + res.statusCode = 200 + res.end() + } else { + res.statusCode = 401 + res.end() + } + }) + await new Promise((resolve) => { server.listen(0, resolve) }) + }) + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)) + }) + + test('Retry interceptor on Client will use the same socket', async () => { + const interceptor = dispatch => { + return (opts, handler) => { + return dispatch(opts, new FakeNtlmRequestHandler(dispatch, opts, handler)) + } + } + const opts = { interceptors: { Client: [interceptor] }, ...defaultOpts } + const agent = new Agent(opts) + const origin = new URL(`http://localhost:${server.address().port}`) + const { statusCode } = await request(origin, { dispatcher: agent, headers: [] }) + expect(statusCode).toEqual(200) + }) +}) diff --git a/test/jest/issue-1757.test.js b/test/jest/issue-1757.test.js new file mode 100644 index 0000000..b6519d9 --- /dev/null +++ b/test/jest/issue-1757.test.js @@ -0,0 +1,61 @@ +'use strict' + +const { Dispatcher, setGlobalDispatcher, MockAgent } = require('../..') + +/* global expect, it */ + +class MiniflareDispatcher extends Dispatcher { + constructor (inner, options) { + super(options) + this.inner = inner + } + + dispatch (options, handler) { + return this.inner.dispatch(options, handler) + } + + close (...args) { + return this.inner.close(...args) + } + + destroy (...args) { + return this.inner.destroy(...args) + } +} + +const runIf = (condition) => condition ? it : it.skip +const nodeMajor = Number(process.versions.node.split('.', 1)[0]) + +runIf(nodeMajor >= 16)('https://github.com/nodejs/undici/issues/1757', async () => { + // fetch isn't exported in <16.8 + const { fetch } = require('../..') + + const mockAgent = new MockAgent() + const mockClient = mockAgent.get('http://localhost:3000') + mockAgent.disableNetConnect() + setGlobalDispatcher(new MiniflareDispatcher(mockAgent)) + + mockClient.intercept({ + path: () => true, + method: () => true + }).reply(200, async (opts) => { + if (opts.body?.[Symbol.asyncIterator]) { + const chunks = [] + for await (const chunk of opts.body) { + chunks.push(chunk) + } + + return Buffer.concat(chunks) + } + + return opts.body + }) + + const response = await fetch('http://localhost:3000', { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }) + }) + + expect(response.json()).resolves.toMatchObject({ foo: 'bar' }) + expect(response.status).toBe(200) +}) diff --git a/test/jest/mock-agent.test.js b/test/jest/mock-agent.test.js new file mode 100644 index 0000000..6f6bac2 --- /dev/null +++ b/test/jest/mock-agent.test.js @@ -0,0 +1,46 @@ +'use strict' + +const { request, setGlobalDispatcher, MockAgent } = require('../..') +const { getResponse } = require('../../lib/mock/mock-utils') + +/* global describe, it, expect */ + +describe('MockAgent', () => { + let mockAgent + + afterEach(() => { + mockAgent.close() + }) + + it('should work in jest', async () => { + expect.assertions(4) + + const baseUrl = 'http://localhost:9999' + + mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + const mockClient = mockAgent.get(baseUrl) + + mockClient.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + expect(statusCode).toBe(200) + expect(headers).toEqual({ 'content-type': 'application/json' }) + expect(trailers).toEqual({ 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + expect(jsonResponse).toEqual({ foo: 'bar' }) + }) +}) diff --git a/test/jest/mock-scope.test.js b/test/jest/mock-scope.test.js new file mode 100644 index 0000000..cab77f6 --- /dev/null +++ b/test/jest/mock-scope.test.js @@ -0,0 +1,32 @@ +const { MockAgent, setGlobalDispatcher, request } = require('../../index') + +/* global afterAll, expect, it, AbortController */ + +const runIf = (condition) => condition ? it : it.skip + +const nodeMajor = Number(process.versions.node.split('.', 1)[0]) +const mockAgent = new MockAgent() + +afterAll(async () => { + await mockAgent.close() +}) + +runIf(nodeMajor >= 16)('Jest works with MockScope.delay - issue #1327', async () => { + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + + const mockPool = mockAgent.get('http://localhost:3333') + + mockPool.intercept({ + path: '/jest-bugs', + method: 'GET' + }).reply(200, 'Hello').delay(100) + + const ac = new AbortController() + setTimeout(() => ac.abort(), 5) + const promise = request('http://localhost:3333/jest-bugs', { + signal: ac.signal + }) + + await expect(promise).rejects.toThrowError('Request aborted') +}, 1000) diff --git a/test/jest/test.js b/test/jest/test.js new file mode 100644 index 0000000..079a41f --- /dev/null +++ b/test/jest/test.js @@ -0,0 +1,36 @@ +'use strict' + +const { Client } = require('../..') +const { createServer } = require('http') +/* global test, expect */ + +test('should work in jest', async () => { + const server = createServer((req, res) => { + expect(req.url).toBe('/') + expect(req.method).toBe('POST') + expect(req.headers.host).toBe(`localhost:${server.address().port}`) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + await expect(new Promise((resolve, reject) => { + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + client.request({ + path: '/', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: '{}' + }, (err, result) => { + server.close() + client.close() + if (err) { + reject(err) + } else { + resolve(result.body.text()) + } + }) + }) + })).resolves.toBe('hello') +}) diff --git a/test/max-headers.js b/test/max-headers.js new file mode 100644 index 0000000..a08b931 --- /dev/null +++ b/test/max-headers.js @@ -0,0 +1,41 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') + +test('handle a lot of headers', (t) => { + t.plan(3) + + const headers = {} + for (let n = 0; n < 64; ++n) { + headers[n] = String(n) + } + + const server = createServer((req, res) => { + res.writeHead(200, headers) + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + const headers2 = {} + for (let n = 0; n < 64; ++n) { + headers2[n] = data.headers[n] + } + t.strictSame(headers2, headers) + data.body + .resume() + .on('end', () => { + t.pass() + }) + }) + }) +}) diff --git a/test/max-response-size.js b/test/max-response-size.js new file mode 100644 index 0000000..75bfade --- /dev/null +++ b/test/max-response-size.js @@ -0,0 +1,105 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const { createServer } = require('http') + +test('max response size', (t) => { + t.plan(4) + + t.test('default max default size should allow all responses', (t) => { + t.plan(3) + + const server = createServer() + t.teardown(server.close.bind(server)) + + server.on('request', (req, res) => { + res.end('hello') + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { maxResponseSize: -1 }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) + + t.test('max response size set to zero should allow only empty responses', (t) => { + t.plan(3) + + const server = createServer() + t.teardown(server.close.bind(server)) + + server.on('request', (req, res) => { + res.end() + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { maxResponseSize: 0 }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) + + t.test('should throw an error if the response is too big', (t) => { + t.plan(3) + + const server = createServer() + t.teardown(server.close.bind(server)) + + server.on('request', (req, res) => { + res.end('hello') + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + maxResponseSize: 1 + }) + + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.error(err) + body.on('error', (err) => { + t.ok(err) + t.type(err, errors.ResponseExceededMaxSizeError) + }) + }) + }) + }) + + t.test('invalid max response size should throw an error', (t) => { + t.plan(2) + + t.throws(() => { + // eslint-disable-next-line no-new + new Client('http://localhost:3000', { maxResponseSize: 'hello' }) + }, 'maxResponseSize must be a number') + t.throws(() => { + // eslint-disable-next-line no-new + new Client('http://localhost:3000', { maxResponseSize: -2 }) + }, 'maxResponseSize must be greater than or equal to -1') + }) +}) diff --git a/test/mock-agent.js b/test/mock-agent.js new file mode 100644 index 0000000..c9ffda4 --- /dev/null +++ b/test/mock-agent.js @@ -0,0 +1,2637 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { promisify } = require('util') +const { request, setGlobalDispatcher, MockAgent, Agent } = require('..') +const { getResponse } = require('../lib/mock/mock-utils') +const { kClients, kConnected } = require('../lib/core/symbols') +const { InvalidArgumentError, ClientDestroyedError } = require('../lib/core/errors') +const { nodeMajor } = require('../lib/core/util') +const MockClient = require('../lib/mock/mock-client') +const MockPool = require('../lib/mock/mock-pool') +const { kAgent } = require('../lib/mock/mock-symbols') +const Dispatcher = require('../lib/dispatcher') +const { MockNotMatchedError } = require('../lib/mock/mock-errors') + +test('MockAgent - constructor', t => { + t.plan(5) + + t.test('sets up mock agent', t => { + t.plan(1) + t.doesNotThrow(() => new MockAgent()) + }) + + t.test('should implement the Dispatcher API', t => { + t.plan(1) + + const mockAgent = new MockAgent() + t.type(mockAgent, Dispatcher) + }) + + t.test('sets up mock agent with single connection', t => { + t.plan(1) + t.doesNotThrow(() => new MockAgent({ connections: 1 })) + }) + + t.test('should error passed agent is not valid', t => { + t.plan(2) + t.throws(() => new MockAgent({ agent: {} }), new InvalidArgumentError('Argument opts.agent must implement Agent')) + t.throws(() => new MockAgent({ agent: { dispatch: '' } }), new InvalidArgumentError('Argument opts.agent must implement Agent')) + }) + + t.test('should be able to specify the agent to mock', t => { + t.plan(1) + const agent = new Agent() + t.teardown(agent.close.bind(agent)) + const mockAgent = new MockAgent({ agent }) + + t.equal(mockAgent[kAgent], agent) + }) +}) + +test('MockAgent - get', t => { + t.plan(3) + + t.test('should return MockClient', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + }) + + t.test('should return MockPool', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + t.type(mockPool, MockPool) + }) + + t.test('should return the same instance if already created', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool1 = mockAgent.get(baseUrl) + const mockPool2 = mockAgent.get(baseUrl) + t.equal(mockPool1, mockPool2) + }) +}) + +test('MockAgent - dispatch', t => { + t.plan(3) + + t.test('should call the dispatch method of the MockPool', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + t.doesNotThrow(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => resume(), + onData: () => {}, + onComplete: () => {}, + onError: () => {} + })) + }) + + t.test('should call the dispatch method of the MockClient', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + t.doesNotThrow(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => resume(), + onData: () => {}, + onComplete: () => {}, + onError: () => {} + })) + }) + + t.test('should throw if handler is not valid on redirect', (t) => { + t.plan(7) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: 'INVALID' + }), new InvalidArgumentError('invalid onError method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: (err) => { throw err }, + onConnect: 'INVALID' + }), new InvalidArgumentError('invalid onConnect method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: (err) => { throw err }, + onConnect: () => {}, + onBodySent: 'INVALID' + }), new InvalidArgumentError('invalid onBodySent method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'CONNECT' + }, { + onError: (err) => { throw err }, + onConnect: () => {}, + onBodySent: () => {}, + onUpgrade: 'INVALID' + }), new InvalidArgumentError('invalid onUpgrade method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: (err) => { throw err }, + onConnect: () => {}, + onBodySent: () => {}, + onHeaders: 'INVALID' + }), new InvalidArgumentError('invalid onHeaders method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: (err) => { throw err }, + onConnect: () => {}, + onBodySent: () => {}, + onHeaders: () => {}, + onData: 'INVALID' + }), new InvalidArgumentError('invalid onData method')) + + t.throws(() => mockAgent.dispatch({ + origin: baseUrl, + path: '/foo', + method: 'GET' + }, { + onError: (err) => { throw err }, + onConnect: () => {}, + onBodySent: () => {}, + onHeaders: () => {}, + onData: () => {}, + onComplete: 'INVALID' + }), new InvalidArgumentError('invalid onComplete method')) + }) +}) + +test('MockAgent - .close should clean up registered pools', async (t) => { + t.plan(5) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + + // Register a pool + const mockPool = mockAgent.get(baseUrl) + t.type(mockPool, MockPool) + + t.equal(mockPool[kConnected], 1) + t.equal(mockAgent[kClients].size, 1) + await mockAgent.close() + t.equal(mockPool[kConnected], 0) + t.equal(mockAgent[kClients].size, 0) +}) + +test('MockAgent - .close should clean up registered clients', async (t) => { + t.plan(5) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + + // Register a pool + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + + t.equal(mockClient[kConnected], 1) + t.equal(mockAgent[kClients].size, 1) + await mockAgent.close() + t.equal(mockClient[kConnected], 0) + t.equal(mockAgent[kClients].size, 0) +}) + +test('MockAgent - [kClients] should match encapsulated agent', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const agent = new Agent() + t.teardown(agent.close.bind(agent)) + + const mockAgent = new MockAgent({ agent }) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + // The MockAgent should encapsulate the input agent clients + t.equal(mockAgent[kClients].size, agent[kClients].size) +}) + +test('MockAgent - basic intercept with MockAgent.request', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await mockAgent.request({ + origin: baseUrl, + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +test('MockAgent - basic intercept with request', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +test('MockAgent - should support local agents', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2', + dispatcher: mockAgent + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +test('MockAgent - should support specifying custom agents to mock', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const agent = new Agent() + t.teardown(agent.close.bind(agent)) + + const mockAgent = new MockAgent({ agent }) + setGlobalDispatcher(mockAgent) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +test('MockAgent - basic Client intercept with request', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +test('MockAgent - basic intercept with multiple pools', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool1 = mockAgent.get(baseUrl) + const mockPool2 = mockAgent.get('http://localhost:9999') + + mockPool1.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar-1' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }) + + mockPool2.intercept({ + path: '/foo?hello=there&see=ya', + method: 'GET', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar-2' }) + + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar-1' + }) +}) + +test('MockAgent - should handle multiple responses for an interceptor', async (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + const interceptor = mockPool.intercept({ + path: '/foo', + method: 'POST' + }) + interceptor.reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + } + }) + interceptor.reply(200, { hello: 'there' }, { + headers: { + 'content-type': 'application/json' + } + }) + + { + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) + } + + { + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + hello: 'there' + }) + } +}) + +test('MockAgent - should call original Pool dispatch if request not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - should call original Client dispatch if request not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - should handle string responses', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - should handle basic concurrency for requests', { jobs: 5 }, async (t) => { + t.plan(5) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + await Promise.all([...Array(5).keys()].map(idx => + t.test(`concurrent job (${idx})`, async (innerTest) => { + innerTest.plan(2) + + const baseUrl = 'http://localhost:9999' + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, { foo: `bar ${idx}` }) + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + innerTest.equal(statusCode, 200) + + const jsonResponse = JSON.parse(await getResponse(body)) + innerTest.same(jsonResponse, { + foo: `bar ${idx}` + }) + }) + )) +}) + +test('MockAgent - handle delays to simulate work', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello').delay(50) + + const start = process.hrtime() + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'hello') + const elapsedInMs = process.hrtime(start)[1] / 1e6 + t.ok(elapsedInMs >= 50, `Elapsed time is not greater than 50ms: ${elapsedInMs}`) +}) + +test('MockAgent - should persist requests', async (t) => { + t.plan(8) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { + 'content-type': 'application/json' + }, + trailers: { 'Content-MD5': 'test' } + }).persist() + + { + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) + } + + { + const { statusCode, headers, trailers, body } = await request(`${baseUrl}/foo?hello=there&see=ya`, { + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) + } +}) + +test('MockAgent - handle persists with delayed requests', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'POST' + }).reply(200, 'hello').delay(1).persist() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'hello') + } + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'POST' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'hello') + } +}) + +test('MockAgent - calling close on a mock pool should not affect other mock pools', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPoolToClose = mockAgent.get('http://localhost:9999') + mockPoolToClose.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'should-not-be-returned') + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + mockPool.intercept({ + path: '/bar', + method: 'POST' + }).reply(200, 'bar') + + await mockPoolToClose.close() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, body } = await request(`${baseUrl}/bar`, { + method: 'POST' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'bar') + } +}) + +test('MockAgent - close removes all registered mock clients', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + setGlobalDispatcher(mockAgent) + + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + await mockAgent.close() + t.equal(mockAgent[kClients].size, 0) + + try { + await request(`${baseUrl}/foo`, { method: 'GET' }) + } catch (err) { + t.type(err, ClientDestroyedError) + } +}) + +test('MockAgent - close removes all registered mock pools', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + await mockAgent.close() + t.equal(mockAgent[kClients].size, 0) + + try { + await request(`${baseUrl}/foo`, { method: 'GET' }) + } catch (err) { + t.type(err, ClientDestroyedError) + } +}) + +test('MockAgent - should handle replyWithError', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).replyWithError(new Error('kaboom')) + + await t.rejects(request(`${baseUrl}/foo`, { method: 'GET' }), new Error('kaboom')) +}) + +test('MockAgent - should support setting a reply to respond a set amount of times', async (t) => { + t.plan(9) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').times(2) + + { + const { statusCode, body } = await request(`${baseUrl}/foo`) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, body } = await request(`${baseUrl}/foo`) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, headers, body } = await request(`${baseUrl}/foo`) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') + } +}) + +test('MockAgent - persist overrides times', async (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').times(2).persist() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } +}) + +test('MockAgent - matcher should not find mock dispatch if path is of unsupported type', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: {}, + method: 'GET' + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - should match path with regex', async (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: /foo/, + method: 'GET' + }).reply(200, 'foo').persist() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + { + const { statusCode, body } = await request(`${baseUrl}/hello/foobar`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } +}) + +test('MockAgent - should match path with function', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: (value) => value === '/foo', + method: 'GET' + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match method with regex', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: /^GET$/ + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match method with function', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: (value) => value === 'GET' + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match body with regex', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + body: /hello/ + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + body: 'hello=there' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match body with function', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + body: (value) => value.startsWith('hello') + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + body: 'hello=there' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match headers with string', async (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } + }).reply(200, 'foo') + + // Disable net connect so we can make sure it matches properly + mockAgent.disableNetConnect() + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET' + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'wrong' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match headers with regex', async (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + headers: { + 'User-Agent': /^undici$/, + Host: /^example.com$/ + } + }).reply(200, 'foo') + + // Disable net connect so we can make sure it matches properly + mockAgent.disableNetConnect() + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET' + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'wrong' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match headers with function', async (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + headers: { + 'User-Agent': (value) => value === 'undici', + Host: (value) => value === 'example.com' + } + }).reply(200, 'foo') + + // Disable net connect so we can make sure it matches properly + mockAgent.disableNetConnect() + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET' + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'wrong' + } + }), MockNotMatchedError, 'should reject with MockNotMatchedError') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match url with regex', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(new RegExp(baseUrl)) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - should match url with function', async (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get((value) => baseUrl === value) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - handle default reply headers', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).defaultReplyHeaders({ foo: 'bar' }).reply(200, 'foo', { headers: { hello: 'there' } }) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.same(headers, { + foo: 'bar', + hello: 'there' + }) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - handle default reply trailers', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).defaultReplyTrailers({ foo: 'bar' }).reply(200, 'foo', { trailers: { hello: 'there' } }) + + const { statusCode, trailers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.same(trailers, { + foo: 'bar', + hello: 'there' + }) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - return calculated content-length if specified', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).replyContentLength().reply(200, 'foo', { headers: { hello: 'there' } }) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.same(headers, { + hello: 'there', + 'content-length': 3 + }) + + const response = await getResponse(body) + t.equal(response, 'foo') +}) + +test('MockAgent - return calculated content-length for object response if specified', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).replyContentLength().reply(200, { foo: 'bar' }, { headers: { hello: 'there' } }) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.same(headers, { + hello: 'there', + 'content-length': 13 + }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { foo: 'bar' }) +}) + +test('MockAgent - should activate and deactivate mock clients', async (t) => { + t.plan(9) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').persist() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } + + mockAgent.deactivate() + + { + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') + } + + mockAgent.activate() + + { + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.equal(response, 'foo') + } +}) + +test('MockAgent - enableNetConnect should allow all original dispatches to be called if dispatch not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect() + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - enableNetConnect with a host string should allow all original dispatches to be called if mockDispatch not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect(`localhost:${server.address().port}`) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - enableNetConnect when called with host string multiple times should allow all original dispatches to be called if mockDispatch not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect('example.com:9999') + mockAgent.enableNetConnect(`localhost:${server.address().port}`) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - enableNetConnect with a host regex should allow all original dispatches to be called if mockDispatch not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect(new RegExp(`localhost:${server.address().port}`)) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - enableNetConnect with a function should allow all original dispatches to be called if mockDispatch not found', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect((value) => value === `localhost:${server.address().port}`) + + const { statusCode, headers, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + const response = await getResponse(body) + t.equal(response, 'hello') +}) + +test('MockAgent - enableNetConnect with an unknown input should throw', async (t) => { + t.plan(1) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:9999') + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + t.throws(() => mockAgent.enableNetConnect({}), new InvalidArgumentError('Unsupported matcher. Must be one of String|Function|RegExp.')) +}) + +test('MockAgent - enableNetConnect should throw if dispatch not matched for path and the origin was not allowed by net connect', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect('example.com:9999') + + await t.rejects(request(`${baseUrl}/wrong`, { + method: 'GET' + }), new MockNotMatchedError(`Mock dispatch not matched for path '/wrong': subsequent request to origin ${baseUrl} was not allowed (net.connect is not enabled for this origin)`)) +}) + +test('MockAgent - enableNetConnect should throw if dispatch not matched for method and the origin was not allowed by net connect', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.enableNetConnect('example.com:9999') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'WRONG' + }), new MockNotMatchedError(`Mock dispatch not matched for method 'WRONG': subsequent request to origin ${baseUrl} was not allowed (net.connect is not enabled for this origin)`)) +}) + +test('MockAgent - enableNetConnect should throw if dispatch not matched for body and the origin was not allowed by net connect', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + body: 'hello' + }).reply(200, 'foo') + + mockAgent.enableNetConnect('example.com:9999') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + body: 'wrong' + }), new MockNotMatchedError(`Mock dispatch not matched for body 'wrong': subsequent request to origin ${baseUrl} was not allowed (net.connect is not enabled for this origin)`)) +}) + +test('MockAgent - enableNetConnect should throw if dispatch not matched for headers and the origin was not allowed by net connect', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/foo', + method: 'GET', + headers: { + 'User-Agent': 'undici' + } + }).reply(200, 'foo') + + mockAgent.enableNetConnect('example.com:9999') + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + 'User-Agent': 'wrong' + } + }), new MockNotMatchedError(`Mock dispatch not matched for headers '{"User-Agent":"wrong"}': subsequent request to origin ${baseUrl} was not allowed (net.connect is not enabled for this origin)`)) +}) + +test('MockAgent - disableNetConnect should throw if dispatch not found by net connect', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.equal(req.url, '/foo') + t.equal(req.method, 'GET') + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + mockPool.intercept({ + path: '/wrong', + method: 'GET' + }).reply(200, 'foo') + + mockAgent.disableNetConnect() + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET' + }), new MockNotMatchedError(`Mock dispatch not matched for path '/foo': subsequent request to origin ${baseUrl} was not allowed (net.connect disabled)`)) +}) + +test('MockAgent - headers function interceptor', async (t) => { + t.plan(7) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool = mockAgent.get(baseUrl) + + // Disable net connect so we can make sure it matches properly + mockAgent.disableNetConnect() + + mockPool.intercept({ + path: '/foo', + method: 'GET', + headers (headers) { + t.equal(typeof headers, 'object') + return !Object.keys(headers).includes('authorization') + } + }).reply(200, 'foo').times(2) + + await t.rejects(request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + Authorization: 'Bearer foo' + } + }), new MockNotMatchedError(`Mock dispatch not matched for headers '{"Authorization":"Bearer foo"}': subsequent request to origin ${baseUrl} was not allowed (net.connect disabled)`)) + + { + const { statusCode } = await request(`${baseUrl}/foo`, { + method: 'GET', + headers: { + foo: 'bar' + } + }) + t.equal(statusCode, 200) + } + + { + const { statusCode } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + } +}) + +test('MockAgent - clients are not garbage collected', async (t) => { + const samples = 250 + t.plan(2) + + const server = createServer((req, res) => { + t.fail('should not be called') + t.end() + res.end('should not be called') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + // Create the dispatcher and isable net connect so we can make sure it matches properly + const dispatcher = new MockAgent() + dispatcher.disableNetConnect() + + // When Node 16 is the minimum supported, this can be replaced by simply requiring setTimeout from timers/promises + function sleep (delay) { + return new Promise(resolve => { + setTimeout(resolve, delay) + }) + } + + // Purposely create the pool inside a function so that the reference is lost + function intercept () { + // Create the pool and add a lot of intercepts + const pool = dispatcher.get(baseUrl) + + for (let i = 0; i < samples; i++) { + pool.intercept({ + path: `/foo/${i}`, + method: 'GET' + }).reply(200, Buffer.alloc(1024 * 1024)) + } + } + + intercept() + + const results = new Set() + for (let i = 0; i < samples; i++) { + // Let's make some time pass to allow garbage collection to happen + await sleep(10) + + const { statusCode } = await request(`${baseUrl}/foo/${i}`, { method: 'GET', dispatcher }) + results.add(statusCode) + } + + t.equal(results.size, 1) + t.ok(results.has(200)) +}) + +// https://github.com/nodejs/undici/issues/1321 +test('MockAgent - using fetch yields correct statusText', { skip: nodeMajor < 16 }, async (t) => { + const { fetch } = require('..') + + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool.intercept({ + path: '/statusText', + method: 'GET' + }).reply(200, 'Body') + + const { status, statusText } = await fetch('http://localhost:3000/statusText') + + t.equal(status, 200) + t.equal(statusText, 'OK') + + mockPool.intercept({ + path: '/unknownStatusText', + method: 'GET' + }).reply(420, 'Everyday') + + const unknownStatusCodeRes = await fetch('http://localhost:3000/unknownStatusText') + t.equal(unknownStatusCodeRes.status, 420) + t.equal(unknownStatusCodeRes.statusText, 'unknown') + + t.end() +}) + +// https://github.com/nodejs/undici/issues/1556 +test('MockAgent - using fetch yields a headers object in the reply callback', { skip: nodeMajor < 16 }, async (t) => { + const { fetch } = require('..') + + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool.intercept({ + path: '/headers', + method: 'GET' + }).reply(200, (opts) => { + t.same(opts.headers, { + accept: '*/*', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'undici', + 'accept-encoding': 'gzip, deflate' + }) + + return {} + }) + + await fetch('http://localhost:3000/headers', { + dispatcher: mockAgent + }) + + t.end() +}) + +// https://github.com/nodejs/undici/issues/1579 +test('MockAgent - headers in mock dispatcher intercept should be case-insensitive', { skip: nodeMajor < 16 }, async (t) => { + const { fetch } = require('..') + + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('https://example.com') + + mockPool + .intercept({ + path: '/', + headers: { + authorization: 'Bearer 12345', + 'USER-agent': 'undici' + } + }) + .reply(200) + + await fetch('https://example.com', { + headers: { + Authorization: 'Bearer 12345', + 'user-AGENT': 'undici' + } + }) + + t.end() +}) + +// https://github.com/nodejs/undici/issues/1757 +test('MockAgent - reply callback can be asynchronous', { skip: nodeMajor < 16 }, async (t) => { + const { fetch } = require('..') + const ReadableStream = globalThis.ReadableStream || require('stream/web').ReadableStream + + class MiniflareDispatcher extends Dispatcher { + constructor (inner, options) { + super(options) + this.inner = inner + } + + dispatch (options, handler) { + return this.inner.dispatch(options, handler) + } + + close (...args) { + return this.inner.close(...args) + } + + destroy (...args) { + return this.inner.destroy(...args) + } + } + + const mockAgent = new MockAgent() + const mockClient = mockAgent.get('http://localhost:3000') + mockAgent.disableNetConnect() + setGlobalDispatcher(new MiniflareDispatcher(mockAgent)) + + t.teardown(mockAgent.close.bind(mockAgent)) + + mockClient.intercept({ + path: () => true, + method: () => true + }).reply(200, async (opts) => { + if (opts.body && opts.body[Symbol.asyncIterator]) { + const chunks = [] + for await (const chunk of opts.body) { + chunks.push(chunk) + } + + return Buffer.concat(chunks) + } + + return opts.body + }).persist() + + { + const response = await fetch('http://localhost:3000', { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }) + }) + + t.same(await response.json(), { foo: 'bar' }) + } + + { + const response = await fetch('http://localhost:3000', { + method: 'POST', + body: new ReadableStream({ + start (controller) { + controller.enqueue(new TextEncoder().encode('{"foo":')) + + setTimeout(() => { + controller.enqueue(new TextEncoder().encode('"bar"}')) + controller.close() + }, 100) + } + }), + duplex: 'half' + }) + + t.same(await response.json(), { foo: 'bar' }) + } +}) + +test('MockAgent - headers should be array of strings', async (t) => { + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo', { + headers: { + 'set-cookie': [ + 'foo=bar', + 'bar=baz', + 'baz=qux' + ] + } + }) + + const { headers } = await request('http://localhost:3000/foo', { + method: 'GET' + }) + + t.same(headers['set-cookie'], [ + 'foo=bar', + 'bar=baz', + 'baz=qux' + ]) +}) + +// https://github.com/nodejs/undici/issues/2418 +test('MockAgent - Sending ReadableStream body', { skip: nodeMajor < 16 }, async (t) => { + t.plan(1) + const { fetch } = require('..') + const ReadableStream = globalThis.ReadableStream || require('stream/web').ReadableStream + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + req.pipe(res) + }) + + t.teardown(mockAgent.close.bind(mockAgent)) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const url = `http://localhost:${server.address().port}` + + const response = await fetch(url, { + method: 'POST', + body: new ReadableStream({ + start (controller) { + controller.enqueue('test') + controller.close() + } + }), + duplex: 'half' + }) + + t.same(await response.text(), 'test') +}) diff --git a/test/mock-client.js b/test/mock-client.js new file mode 100644 index 0000000..ef0600e --- /dev/null +++ b/test/mock-client.js @@ -0,0 +1,446 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { promisify } = require('util') +const { MockAgent, MockClient, setGlobalDispatcher, request } = require('..') +const { kUrl } = require('../lib/core/symbols') +const { kDispatches } = require('../lib/mock/mock-symbols') +const { InvalidArgumentError } = require('../lib/core/errors') +const { MockInterceptor } = require('../lib/mock/mock-interceptor') +const { getResponse } = require('../lib/mock/mock-utils') +const Dispatcher = require('../lib/dispatcher') + +test('MockClient - constructor', t => { + t.plan(3) + + t.test('fails if opts.agent does not implement `get` method', t => { + t.plan(1) + t.throws(() => new MockClient('http://localhost:9999', { agent: { get: 'not a function' } }), InvalidArgumentError) + }) + + t.test('sets agent', t => { + t.plan(1) + t.doesNotThrow(() => new MockClient('http://localhost:9999', { agent: new MockAgent({ connections: 1 }) })) + }) + + t.test('should implement the Dispatcher API', t => { + t.plan(1) + + const mockClient = new MockClient('http://localhost:9999', { agent: new MockAgent({ connections: 1 }) }) + t.type(mockClient, Dispatcher) + }) +}) + +test('MockClient - dispatch', t => { + t.plan(2) + + t.test('should handle a single interceptor', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + + this[kUrl] = new URL('http://localhost:9999') + mockClient[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + t.doesNotThrow(() => mockClient.dispatch({ + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => resume(), + onData: () => {}, + onComplete: () => {} + })) + }) + + t.test('should directly throw error from mockDispatch function if error is not a MockNotMatchedError', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + + this[kUrl] = new URL('http://localhost:9999') + mockClient[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + t.throws(() => mockClient.dispatch({ + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => { throw new Error('kaboom') }, + onData: () => {}, + onComplete: () => {} + }), new Error('kaboom')) + }) +}) + +test('MockClient - intercept should return a MockInterceptor', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + + const interceptor = mockClient.intercept({ + path: '/foo', + method: 'GET' + }) + + t.type(interceptor, MockInterceptor) +}) + +test('MockClient - intercept validation', (t) => { + t.plan(4) + + t.test('it should error if no options specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get('http://localhost:9999') + + t.throws(() => mockClient.intercept(), new InvalidArgumentError('opts must be an object')) + }) + + t.test('it should error if no path specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get('http://localhost:9999') + + t.throws(() => mockClient.intercept({}), new InvalidArgumentError('opts.path must be defined')) + }) + + t.test('it should default to GET if no method specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get('http://localhost:9999') + t.doesNotThrow(() => mockClient.intercept({ path: '/foo' })) + }) + + t.test('it should uppercase the method - https://github.com/nodejs/undici/issues/1320', t => { + t.plan(1) + + const mockAgent = new MockAgent() + const mockClient = mockAgent.get('http://localhost:3000') + + t.teardown(mockAgent.close.bind(mockAgent)) + + mockClient.intercept({ + path: '/test', + method: 'patch' + }).reply(200, 'Hello!') + + t.equal(mockClient[kDispatches][0].method, 'PATCH') + }) +}) + +test('MockClient - close should run without error', async (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + mockClient[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + await t.resolves(mockClient.close()) +}) + +test('MockClient - should be able to set as globalDispatcher', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + setGlobalDispatcher(mockClient) + + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockClient - should support query params', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + setGlobalDispatcher(mockClient) + + const query = { + pageNum: 1 + } + mockClient.intercept({ + path: '/foo', + query, + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + query + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockClient - should intercept query params with hardcoded path', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + setGlobalDispatcher(mockClient) + + const query = { + pageNum: 1 + } + mockClient.intercept({ + path: '/foo?pageNum=1', + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + query + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockClient - should intercept query params regardless of key ordering', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + setGlobalDispatcher(mockClient) + + const query = { + pageNum: 1, + limit: 100, + ordering: [false, true] + } + + mockClient.intercept({ + path: '/foo', + query: { + ordering: query.ordering, + pageNum: query.pageNum, + limit: query.limit + }, + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + query + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockClient - should be able to use as a local dispatcher', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + dispatcher: mockClient + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockClient - basic intercept with MockClient.request', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ connections: 1 }) + t.teardown(mockAgent.close.bind(mockAgent)) + const mockClient = mockAgent.get(baseUrl) + t.type(mockClient, MockClient) + + mockClient.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await mockClient.request({ + origin: baseUrl, + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) diff --git a/test/mock-errors.js b/test/mock-errors.js new file mode 100644 index 0000000..a96de0b --- /dev/null +++ b/test/mock-errors.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('tap') +const { mockErrors, errors } = require('..') + +test('mockErrors', (t) => { + t.plan(1) + + t.test('MockNotMatchedError', t => { + t.plan(2) + + t.test('should implement an UndiciError', t => { + t.plan(4) + + const mockError = new mockErrors.MockNotMatchedError() + t.type(mockError, errors.UndiciError) + t.same(mockError.name, 'MockNotMatchedError') + t.same(mockError.code, 'UND_MOCK_ERR_MOCK_NOT_MATCHED') + t.same(mockError.message, 'The request does not match any registered mock dispatches') + }) + + t.test('should set a custom message', t => { + t.plan(4) + + const mockError = new mockErrors.MockNotMatchedError('custom message') + t.type(mockError, errors.UndiciError) + t.same(mockError.name, 'MockNotMatchedError') + t.same(mockError.code, 'UND_MOCK_ERR_MOCK_NOT_MATCHED') + t.same(mockError.message, 'custom message') + }) + }) +}) diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js new file mode 100644 index 0000000..2fe8b82 --- /dev/null +++ b/test/mock-interceptor-unused-assertions.js @@ -0,0 +1,268 @@ +'use strict' + +const { test, beforeEach, afterEach } = require('tap') +const { MockAgent, setGlobalDispatcher } = require('..') +const PendingInterceptorsFormatter = require('../lib/mock/pending-interceptors-formatter') +const util = require('../lib/core/util') + +// Since Node.js v21 `console.table` rows are aligned to the left +// https://github.com/nodejs/node/pull/50135 +const tableRowsAlignedToLeft = util.nodeMajor >= 21 || (util.nodeMajor === 20 && util.nodeMinor >= 11) + +// Avoid colors in the output for inline snapshots. +const pendingInterceptorsFormatter = new PendingInterceptorsFormatter({ disableColors: true }) + +let originalGlobalDispatcher + +const origin = 'https://localhost:9999' + +beforeEach(() => { + // Disallow all network activity by default by using a mock agent as the global dispatcher + const globalDispatcher = new MockAgent() + globalDispatcher.disableNetConnect() + setGlobalDispatcher(globalDispatcher) + originalGlobalDispatcher = globalDispatcher +}) + +afterEach(() => { + setGlobalDispatcher(originalGlobalDispatcher) +}) + +function mockAgentWithOneInterceptor () { + const agent = new MockAgent() + agent.disableNetConnect() + + agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200, '') + + return agent +} + +test('1 pending interceptor', t => { + t.plan(2) + + const err = t.throws(() => mockAgentWithOneInterceptor().assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, tableRowsAlignedToLeft + ? ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim() + : ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('2 pending interceptors', t => { + t.plan(2) + + const withTwoInterceptors = mockAgentWithOneInterceptor() + withTwoInterceptors + .get(origin) + .intercept({ method: 'get', path: '/some/path' }) + .reply(204, 'OK') + const err = t.throws(() => withTwoInterceptors.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, tableRowsAlignedToLeft + ? ` +2 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴──────────────────────────┴──────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim() + : ` +2 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴──────────────────────────┴──────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('Variations of persist(), times(), and pending status', async t => { + t.plan(7) + + // Agent with unused interceptor + const agent = mockAgentWithOneInterceptor() + + // Unused with persist() + agent + .get(origin) + .intercept({ method: 'get', path: '/persistent/unused' }) + .reply(200, 'OK') + .persist() + + // Used with persist() + agent + .get(origin) + .intercept({ method: 'GET', path: '/persistent/used' }) + .reply(200, 'OK') + .persist() + t.same((await agent.request({ origin, method: 'GET', path: '/persistent/used' })).statusCode, 200) + + // Consumed without persist() + agent.get(origin) + .intercept({ method: 'post', path: '/transient/pending' }) + .reply(201, 'Created') + t.same((await agent.request({ origin, method: 'POST', path: '/transient/pending' })).statusCode, 201) + + // Partially pending with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/partial' }) + .reply(200, 'OK') + .times(5) + t.same((await agent.request({ origin, method: 'GET', path: '/times/partial' })).statusCode, 200) + + // Unused with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/unused' }) + .reply(200, 'OK') + .times(2) + + // Fully pending with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/pending' }) + .reply(200, 'OK') + .times(2) + t.same((await agent.request({ origin, method: 'GET', path: '/times/pending' })).statusCode, 200) + t.same((await agent.request({ origin, method: 'GET', path: '/times/pending' })).statusCode, 200) + + const err = t.throws(() => agent.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, tableRowsAlignedToLeft + ? ` +4 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '✅' │ 0 │ Infinity │ +│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '❌' │ 1 │ 4 │ +│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '❌' │ 0 │ 2 │ +└─────────┴────────┴──────────────────────────┴──────────────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim() + : ` +4 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '✅' │ 0 │ Infinity │ +│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '❌' │ 1 │ 4 │ +│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '❌' │ 0 │ 2 │ +└─────────┴────────┴──────────────────────────┴──────────────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('works when no interceptors are registered', t => { + t.plan(2) + + const agent = new MockAgent() + agent.disableNetConnect() + + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) +}) + +test('works when all interceptors are pending', async t => { + t.plan(4) + + const agent = new MockAgent() + agent.disableNetConnect() + + agent.get(origin).intercept({ method: 'get', path: '/' }).reply(200, 'OK') + t.same((await agent.request({ origin, method: 'GET', path: '/' })).statusCode, 200) + + agent.get(origin).intercept({ method: 'get', path: '/persistent' }).reply(200, 'OK') + t.same((await agent.request({ origin, method: 'GET', path: '/persistent' })).statusCode, 200) + + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) +}) + +test('defaults to rendering output with terminal color when process.env.CI is unset', t => { + t.plan(2) + + // This ensures that the test works in an environment where the CI env var is set. + const oldCiEnvVar = process.env.CI + delete process.env.CI + + const err = t.throws( + () => mockAgentWithOneInterceptor().assertNoPendingInterceptors()) + t.same(err.message, tableRowsAlignedToLeft + ? ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'❌'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim() + : ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'❌'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) + + // Re-set the CI env var if it were set. + // Assigning `undefined` does not work, + // because reading the env var afterwards yields the string 'undefined', + // so we need to re-set it conditionally. + if (oldCiEnvVar != null) { + process.env.CI = oldCiEnvVar + } +}) + +test('returns unused interceptors', t => { + t.plan(1) + + t.same(mockAgentWithOneInterceptor().pendingInterceptors(), [ + { + timesInvoked: 0, + times: 1, + persist: false, + consumed: false, + pending: true, + path: '/', + method: 'GET', + body: undefined, + query: undefined, + headers: undefined, + data: { + error: null, + statusCode: 200, + data: '', + headers: {}, + trailers: {} + }, + origin: 'https://example.com' + } + ]) +}) diff --git a/test/mock-interceptor.js b/test/mock-interceptor.js new file mode 100644 index 0000000..a11377d --- /dev/null +++ b/test/mock-interceptor.js @@ -0,0 +1,258 @@ +'use strict' + +const { test } = require('tap') +const { MockInterceptor, MockScope } = require('../lib/mock/mock-interceptor') +const MockAgent = require('../lib/mock/mock-agent') +const { kDispatchKey } = require('../lib/mock/mock-symbols') +const { InvalidArgumentError } = require('../lib/core/errors') + +test('MockInterceptor - path', t => { + t.plan(1) + t.test('should remove hash fragment from paths', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '#foobar', + method: '' + }, []) + t.equal(mockInterceptor[kDispatchKey].path, '') + }) +}) + +test('MockInterceptor - reply', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.reply(200, 'hello') + t.type(result, MockScope) + }) + + t.test('should error if passed options invalid', t => { + t.plan(2) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + t.throws(() => mockInterceptor.reply(), new InvalidArgumentError('statusCode must be defined')) + t.throws(() => mockInterceptor.reply(200, '', 'hello'), new InvalidArgumentError('responseOptions must be an object')) + }) +}) + +test('MockInterceptor - reply callback', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.reply(200, () => 'hello') + t.type(result, MockScope) + }) + + t.test('should error if passed options invalid', t => { + t.plan(2) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + t.throws(() => mockInterceptor.reply(), new InvalidArgumentError('statusCode must be defined')) + t.throws(() => mockInterceptor.reply(200, () => {}, 'hello'), new InvalidArgumentError('responseOptions must be an object')) + }) +}) + +test('MockInterceptor - reply options callback', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(2) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.reply((options) => ({ + statusCode: 200, + data: 'hello' + })) + t.type(result, MockScope) + + // Test parameters + + const baseUrl = 'http://localhost:9999' + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/test', + method: 'GET' + }).reply((options) => { + t.strictSame(options, { path: '/test', method: 'GET', headers: { foo: 'bar' } }) + return { statusCode: 200, data: 'hello' } + }) + + mockPool.dispatch({ + path: '/test', + method: 'GET', + headers: { foo: 'bar' } + }, { + onHeaders: () => {}, + onData: () => {}, + onComplete: () => {} + }) + }) + + t.test('should error if passed options invalid', async (t) => { + t.plan(3) + + const baseUrl = 'http://localhost:9999' + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/test', + method: 'GET' + }).reply(() => {}) + + mockPool.intercept({ + path: '/test3', + method: 'GET' + }).reply(() => ({ + statusCode: 200, + data: 'hello', + responseOptions: 42 + })) + + mockPool.intercept({ + path: '/test4', + method: 'GET' + }).reply(() => ({ + data: 'hello', + responseOptions: 42 + })) + + t.throws(() => mockPool.dispatch({ + path: '/test', + method: 'GET' + }, { + onHeaders: () => {}, + onData: () => {}, + onComplete: () => {} + }), new InvalidArgumentError('reply options callback must return an object')) + + t.throws(() => mockPool.dispatch({ + path: '/test3', + method: 'GET' + }, { + onHeaders: () => {}, + onData: () => {}, + onComplete: () => {} + }), new InvalidArgumentError('responseOptions must be an object')) + + t.throws(() => mockPool.dispatch({ + path: '/test4', + method: 'GET' + }, { + onHeaders: () => {}, + onData: () => {}, + onComplete: () => {} + }), new InvalidArgumentError('statusCode must be defined')) + }) +}) + +test('MockInterceptor - replyWithError', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.replyWithError(new Error('kaboom')) + t.type(result, MockScope) + }) + + t.test('should error if passed options invalid', t => { + t.plan(1) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + t.throws(() => mockInterceptor.replyWithError(), new InvalidArgumentError('error must be defined')) + }) +}) + +test('MockInterceptor - defaultReplyHeaders', t => { + t.plan(2) + + t.test('should return MockInterceptor', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.defaultReplyHeaders({}) + t.type(result, MockInterceptor) + }) + + t.test('should error if passed options invalid', t => { + t.plan(1) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + t.throws(() => mockInterceptor.defaultReplyHeaders(), new InvalidArgumentError('headers must be defined')) + }) +}) + +test('MockInterceptor - defaultReplyTrailers', t => { + t.plan(2) + + t.test('should return MockInterceptor', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.defaultReplyTrailers({}) + t.type(result, MockInterceptor) + }) + + t.test('should error if passed options invalid', t => { + t.plan(1) + + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + t.throws(() => mockInterceptor.defaultReplyTrailers(), new InvalidArgumentError('trailers must be defined')) + }) +}) + +test('MockInterceptor - replyContentLength', t => { + t.plan(1) + + t.test('should return MockInterceptor', t => { + t.plan(1) + const mockInterceptor = new MockInterceptor({ + path: '', + method: '' + }, []) + const result = mockInterceptor.defaultReplyTrailers({}) + t.type(result, MockInterceptor) + }) +}) diff --git a/test/mock-pool.js b/test/mock-pool.js new file mode 100644 index 0000000..0ac1aac --- /dev/null +++ b/test/mock-pool.js @@ -0,0 +1,369 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { promisify } = require('util') +const { MockAgent, MockPool, getGlobalDispatcher, setGlobalDispatcher, request } = require('..') +const { kUrl } = require('../lib/core/symbols') +const { nodeMajor } = require('../lib/core/util') +const { kDispatches } = require('../lib/mock/mock-symbols') +const { InvalidArgumentError } = require('../lib/core/errors') +const { MockInterceptor } = require('../lib/mock/mock-interceptor') +const { getResponse } = require('../lib/mock/mock-utils') +const Dispatcher = require('../lib/dispatcher') + +test('MockPool - constructor', t => { + t.plan(3) + + t.test('fails if opts.agent does not implement `get` method', t => { + t.plan(1) + t.throws(() => new MockPool('http://localhost:9999', { agent: { get: 'not a function' } }), InvalidArgumentError) + }) + + t.test('sets agent', t => { + t.plan(1) + t.doesNotThrow(() => new MockPool('http://localhost:9999', { agent: new MockAgent() })) + }) + + t.test('should implement the Dispatcher API', t => { + t.plan(1) + + const mockPool = new MockPool('http://localhost:9999', { agent: new MockAgent() }) + t.type(mockPool, Dispatcher) + }) +}) + +test('MockPool - dispatch', t => { + t.plan(2) + + t.test('should handle a single interceptor', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + this[kUrl] = new URL('http://localhost:9999') + mockPool[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + t.doesNotThrow(() => mockPool.dispatch({ + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => resume(), + onData: () => {}, + onComplete: () => {} + })) + }) + + t.test('should directly throw error from mockDispatch function if error is not a MockNotMatchedError', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + this[kUrl] = new URL('http://localhost:9999') + mockPool[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + t.throws(() => mockPool.dispatch({ + path: '/foo', + method: 'GET' + }, { + onHeaders: (_statusCode, _headers, resume) => { throw new Error('kaboom') }, + onData: () => {}, + onComplete: () => {} + }), new Error('kaboom')) + }) +}) + +test('MockPool - intercept should return a MockInterceptor', (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + const interceptor = mockPool.intercept({ + path: '/foo', + method: 'GET' + }) + + t.ok(interceptor instanceof MockInterceptor) +}) + +test('MockPool - intercept validation', (t) => { + t.plan(3) + + t.test('it should error if no options specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:9999') + + t.throws(() => mockPool.intercept(), new InvalidArgumentError('opts must be an object')) + }) + + t.test('it should error if no path specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:9999') + + t.throws(() => mockPool.intercept({}), new InvalidArgumentError('opts.path must be defined')) + }) + + t.test('it should default to GET if no method specified in the intercept', t => { + t.plan(1) + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get('http://localhost:9999') + t.doesNotThrow(() => mockPool.intercept({ path: '/foo' })) + }) +}) + +test('MockPool - close should run without error', async (t) => { + t.plan(1) + + const baseUrl = 'http://localhost:9999' + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + + mockPool[kDispatches] = [ + { + path: '/foo', + method: 'GET', + data: { + statusCode: 200, + data: 'hello', + headers: {}, + trailers: {}, + error: null + } + } + ] + + await t.resolves(mockPool.close()) +}) + +test('MockPool - should be able to set as globalDispatcher', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + t.type(mockPool, MockPool) + setGlobalDispatcher(mockPool) + + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET' + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockPool - should be able to use as a local dispatcher', async (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + + const mockPool = mockAgent.get(baseUrl) + t.type(mockPool, MockPool) + + mockPool.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'hello') + + const { statusCode, body } = await request(`${baseUrl}/foo`, { + method: 'GET', + dispatcher: mockPool + }) + t.equal(statusCode, 200) + + const response = await getResponse(body) + t.same(response, 'hello') +}) + +test('MockPool - basic intercept with MockPool.request', async (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + t.teardown(mockAgent.close.bind(mockAgent)) + const mockPool = mockAgent.get(baseUrl) + t.type(mockPool, MockPool) + + mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await mockPool.request({ + origin: baseUrl, + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' + }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.same(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.same(jsonResponse, { + foo: 'bar' + }) +}) + +// https://github.com/nodejs/undici/issues/1546 +test('MockPool - correct errors when consuming invalid JSON body', async (t) => { + const oldDispatcher = getGlobalDispatcher() + + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + + t.teardown(() => setGlobalDispatcher(oldDispatcher)) + + const mockPool = mockAgent.get('https://google.com') + mockPool.intercept({ + path: 'https://google.com' + }).reply(200, 'it\'s just a text') + + const { body } = await request('https://google.com') + await t.rejects(body.json(), SyntaxError) + + t.end() +}) + +test('MockPool - allows matching headers in fetch', { skip: nodeMajor < 16 }, async (t) => { + const { fetch } = require('../index') + + const oldDispatcher = getGlobalDispatcher() + + const baseUrl = 'http://localhost:9999' + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + + t.teardown(async () => { + await mockAgent.close() + setGlobalDispatcher(oldDispatcher) + }) + + const pool = mockAgent.get(baseUrl) + pool.intercept({ + path: '/foo', + method: 'GET', + headers: { + accept: 'application/json' + } + }).reply(200, { ok: 1 }).times(3) + + await t.resolves( + fetch(`${baseUrl}/foo`, { + headers: { + accept: 'application/json' + } + }) + ) + + // no 'accept: application/json' header sent, not matched + await t.rejects(fetch(`${baseUrl}/foo`)) + + // not 'accept: application/json', not matched + await t.rejects(fetch(`${baseUrl}/foo`), { + headers: { + accept: 'text/plain' + } + }, TypeError) + + t.end() +}) diff --git a/test/mock-scope.js b/test/mock-scope.js new file mode 100644 index 0000000..605ba58 --- /dev/null +++ b/test/mock-scope.js @@ -0,0 +1,73 @@ +'use strict' + +const { test } = require('tap') +const { MockScope } = require('../lib/mock/mock-interceptor') +const { InvalidArgumentError } = require('../lib/core/errors') + +test('MockScope - delay', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(1) + const mockScope = new MockScope({ + path: '', + method: '' + }, []) + const result = mockScope.delay(200) + t.type(result, MockScope) + }) + + t.test('should error if passed options invalid', t => { + t.plan(4) + + const mockScope = new MockScope({ + path: '', + method: '' + }, []) + t.throws(() => mockScope.delay(), new InvalidArgumentError('waitInMs must be a valid integer > 0')) + t.throws(() => mockScope.delay(200.1), new InvalidArgumentError('waitInMs must be a valid integer > 0')) + t.throws(() => mockScope.delay(0), new InvalidArgumentError('waitInMs must be a valid integer > 0')) + t.throws(() => mockScope.delay(-1), new InvalidArgumentError('waitInMs must be a valid integer > 0')) + }) +}) + +test('MockScope - persist', t => { + t.plan(1) + + t.test('should return MockScope', t => { + t.plan(1) + const mockScope = new MockScope({ + path: '', + method: '' + }, []) + const result = mockScope.persist() + t.type(result, MockScope) + }) +}) + +test('MockScope - times', t => { + t.plan(2) + + t.test('should return MockScope', t => { + t.plan(1) + const mockScope = new MockScope({ + path: '', + method: '' + }, []) + const result = mockScope.times(200) + t.type(result, MockScope) + }) + + t.test('should error if passed options invalid', t => { + t.plan(4) + + const mockScope = new MockScope({ + path: '', + method: '' + }, []) + t.throws(() => mockScope.times(), new InvalidArgumentError('repeatTimes must be a valid integer > 0')) + t.throws(() => mockScope.times(200.1), new InvalidArgumentError('repeatTimes must be a valid integer > 0')) + t.throws(() => mockScope.times(0), new InvalidArgumentError('repeatTimes must be a valid integer > 0')) + t.throws(() => mockScope.times(-1), new InvalidArgumentError('repeatTimes must be a valid integer > 0')) + }) +}) diff --git a/test/mock-utils.js b/test/mock-utils.js new file mode 100644 index 0000000..7799803 --- /dev/null +++ b/test/mock-utils.js @@ -0,0 +1,160 @@ +'use strict' + +const { test } = require('tap') +const { nodeMajor } = require('../lib/core/util') +const { MockNotMatchedError } = require('../lib/mock/mock-errors') +const { + deleteMockDispatch, + getMockDispatch, + getResponseData, + getStatusText, + getHeaderByName +} = require('../lib/mock/mock-utils') + +test('deleteMockDispatch - should do nothing if not able to find mock dispatch', (t) => { + t.plan(1) + + const key = { + url: 'url', + path: 'path', + method: 'method', + body: 'body' + } + + t.doesNotThrow(() => deleteMockDispatch([], key)) +}) + +test('getMockDispatch', (t) => { + t.plan(3) + + t.test('it should find a mock dispatch', (t) => { + t.plan(1) + const dispatches = [ + { + path: 'path', + method: 'method', + consumed: false + } + ] + + const result = getMockDispatch(dispatches, { + path: 'path', + method: 'method' + }) + t.same(result, { + path: 'path', + method: 'method', + consumed: false + }) + }) + + t.test('it should skip consumed dispatches', (t) => { + t.plan(1) + const dispatches = [ + { + path: 'path', + method: 'method', + consumed: true + }, + { + path: 'path', + method: 'method', + consumed: false + } + ] + + const result = getMockDispatch(dispatches, { + path: 'path', + method: 'method' + }) + t.same(result, { + path: 'path', + method: 'method', + consumed: false + }) + }) + + t.test('it should throw if dispatch not found', (t) => { + t.plan(1) + const dispatches = [ + { + path: 'path', + method: 'method', + consumed: false + } + ] + + t.throws(() => getMockDispatch(dispatches, { + path: 'wrong', + method: 'wrong' + }), new MockNotMatchedError('Mock dispatch not matched for path \'wrong\'')) + }) +}) + +test('getResponseData', (t) => { + t.plan(3) + + t.test('it should stringify objects', (t) => { + t.plan(1) + const responseData = getResponseData({ str: 'string', num: 42 }) + t.equal(responseData, '{"str":"string","num":42}') + }) + + t.test('it should return strings untouched', (t) => { + t.plan(1) + const responseData = getResponseData('test') + t.equal(responseData, 'test') + }) + + t.test('it should return buffers untouched', (t) => { + t.plan(1) + const responseData = getResponseData(Buffer.from('test')) + t.ok(Buffer.isBuffer(responseData)) + }) +}) + +test('getStatusText', (t) => { + for (const statusCode of [ + 100, 101, 102, 103, 200, 201, 202, 203, + 204, 205, 206, 207, 208, 226, 300, 301, + 302, 303, 304, 305, 306, 307, 308, 400, + 401, 402, 403, 404, 405, 406, 407, 408, + 409, 410, 411, 412, 413, 414, 415, 416, + 417, 418, 421, 422, 423, 424, 425, 426, + 428, 429, 431, 451, 500, 501, 502, 503, + 504, 505, 506, 507, 508, 510, 511 + ]) { + t.ok(getStatusText(statusCode)) + } + + t.equal(getStatusText(420), 'unknown') + + t.end() +}) + +test('getHeaderByName', (t) => { + const headersRecord = { + key: 'value' + } + + t.equal(getHeaderByName(headersRecord, 'key'), 'value') + t.equal(getHeaderByName(headersRecord, 'anotherKey'), undefined) + + const headersArray = ['key', 'value'] + + t.equal(getHeaderByName(headersArray, 'key'), 'value') + t.equal(getHeaderByName(headersArray, 'anotherKey'), undefined) + + if (nodeMajor >= 16) { + const { Headers } = require('../index') + + const headers = new Headers([ + ['key', 'value'] + ]) + + t.equal(getHeaderByName(headers, 'key'), 'value') + t.equal(getHeaderByName(headers, 'anotherKey'), null) + } + + t.end() +}) diff --git a/test/no-strict-content-length.js b/test/no-strict-content-length.js new file mode 100644 index 0000000..993b0fd --- /dev/null +++ b/test/no-strict-content-length.js @@ -0,0 +1,349 @@ +'use strict' + +const tap = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const sinon = require('sinon') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') + +tap.test('strictContentLength: false', (t) => { + t.plan(7) + + const emitWarningStub = sinon.stub(process, 'emitWarning') + + function assertEmitWarningCalledAndReset () { + sinon.assert.called(emitWarningStub) + emitWarningStub.resetHistory() + } + + t.teardown(() => { + emitWarningStub.restore() + }) + + t.test('request invalid content-length', (t) => { + t.plan(8) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asd' + }, (err, data) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asdasdasdasdasdasda' + }, (err, data) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(9) + }, (err, data) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(11) + }, (err, data) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + + client.request({ + path: '/', + method: 'HEAD', + headers: { + 'content-length': 10 + } + }, (err, data) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 0 + } + }, (err, data) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + }, (err, data) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asasdasdasdd') + this.push(null) + } + }) + }, (err, data) => { + t.error(err) + }) + }) + }) + + t.test('request streaming content-length less than body size', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 2 + }, + body: new Readable({ + read () { + setImmediate(() => { + this.push('abcd') + this.push(null) + }) + } + }) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) + + t.test('request streaming content-length greater than body size', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: new Readable({ + read () { + setImmediate(() => { + this.push('abcd') + this.push(null) + }) + } + }) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) + + t.test('request streaming data when content-length=0', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + }, + body: new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + }) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) + + t.test('request async iterating content-length less than body size', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 2 + }, + body: wrapWithAsyncIterable(new Readable({ + read () { + setImmediate(() => { + this.push('abcd') + this.push(null) + }) + } + })) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) + + t.test('request async iterator content-length greater than body size', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: wrapWithAsyncIterable(new Readable({ + read () { + setImmediate(() => { + this.push('abcd') + this.push(null) + }) + } + })) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) + + t.test('request async iterator data when content-length=0', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + strictContentLength: false + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + }, + body: wrapWithAsyncIterable(new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + })) + }, (err) => { + assertEmitWarningCalledAndReset() + t.error(err) + }) + }) + }) +}) diff --git a/test/node-fetch/LICENSE b/test/node-fetch/LICENSE new file mode 100644 index 0000000..41ca1b6 --- /dev/null +++ b/test/node-fetch/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 - 2020 Node Fetch Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/test/node-fetch/headers.js b/test/node-fetch/headers.js new file mode 100644 index 0000000..e509fd8 --- /dev/null +++ b/test/node-fetch/headers.js @@ -0,0 +1,282 @@ +/* eslint no-unused-expressions: "off" */ + +const { format } = require('util') +const chai = require('chai') +const chaiIterator = require('chai-iterator') +const { Headers } = require('../../lib/fetch/headers.js') + +chai.use(chaiIterator) + +const { expect } = chai + +describe('Headers', () => { + it('should have attributes conforming to Web IDL', () => { + const headers = new Headers() + expect(Object.getOwnPropertyNames(headers)).to.be.empty + const enumerableProperties = [] + + for (const property in headers) { + enumerableProperties.push(property) + } + + for (const toCheck of [ + 'append', + 'delete', + 'entries', + 'forEach', + 'get', + 'has', + 'keys', + 'set', + 'values' + ]) { + expect(enumerableProperties).to.contain(toCheck) + } + }) + + it('should allow iterating through all headers with forEach', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['b', '3'], + ['a', '1'] + ]) + expect(headers).to.have.property('forEach') + + const result = [] + for (const [key, value] of headers.entries()) { + result.push([key, value]) + } + + expect(result).to.deep.equal([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]) + }) + + it('should be iterable with forEach', () => { + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Accept', 'text/plain') + headers.append('Content-Type', 'text/html') + + const results = [] + headers.forEach((value, key, object) => { + results.push({ value, key, object }) + }) + + expect(results.length).to.equal(2) + expect({ key: 'accept', value: 'application/json, text/plain', object: headers }).to.deep.equal(results[0]) + expect({ key: 'content-type', value: 'text/html', object: headers }).to.deep.equal(results[1]) + }) + + xit('should set "this" to undefined by default on forEach', () => { + const headers = new Headers({ Accept: 'application/json' }) + headers.forEach(function () { + expect(this).to.be.undefined + }) + }) + + it('should accept thisArg as a second argument for forEach', () => { + const headers = new Headers({ Accept: 'application/json' }) + const thisArg = {} + headers.forEach(function () { + expect(this).to.equal(thisArg) + }, thisArg) + }) + + it('should allow iterating through all headers with for-of loop', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]) + headers.append('b', '3') + expect(headers).to.be.iterable + + const result = [] + for (const pair of headers) { + result.push(pair) + } + + expect(result).to.deep.equal([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]) + }) + + it('should allow iterating through all headers with entries()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]) + headers.append('b', '3') + + expect(headers.entries()).to.be.iterable + .and.to.deep.iterate.over([ + ['a', '1'], + ['b', '2, 3'], + ['c', '4'] + ]) + }) + + it('should allow iterating through all headers with keys()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]) + headers.append('b', '3') + + expect(headers.keys()).to.be.iterable + .and.to.iterate.over(['a', 'b', 'c']) + }) + + it('should allow iterating through all headers with values()', () => { + const headers = new Headers([ + ['b', '2'], + ['c', '4'], + ['a', '1'] + ]) + headers.append('b', '3') + + expect(headers.values()).to.be.iterable + .and.to.iterate.over(['1', '2, 3', '4']) + }) + + it('should reject illegal header', () => { + const headers = new Headers() + expect(() => new Headers({ 'He y': 'ok' })).to.throw(TypeError) + expect(() => new Headers({ 'Hé-y': 'ok' })).to.throw(TypeError) + expect(() => new Headers({ 'He-y': 'ăk' })).to.throw(TypeError) + expect(() => headers.append('Hé-y', 'ok')).to.throw(TypeError) + expect(() => headers.delete('Hé-y')).to.throw(TypeError) + expect(() => headers.get('Hé-y')).to.throw(TypeError) + expect(() => headers.has('Hé-y')).to.throw(TypeError) + expect(() => headers.set('Hé-y', 'ok')).to.throw(TypeError) + // Should reject empty header + expect(() => headers.append('', 'ok')).to.throw(TypeError) + }) + + xit('should ignore unsupported attributes while reading headers', () => { + const FakeHeader = function () {} + // Prototypes are currently ignored + // This might change in the future: #181 + FakeHeader.prototype.z = 'fake' + + const res = new FakeHeader() + res.a = 'string' + res.b = ['1', '2'] + res.c = '' + res.d = [] + res.e = 1 + res.f = [1, 2] + res.g = { a: 1 } + res.h = undefined + res.i = null + res.j = Number.NaN + res.k = true + res.l = false + res.m = Buffer.from('test') + + const h1 = new Headers(res) + h1.set('n', [1, 2]) + h1.append('n', ['3', 4]) + + const h1Raw = h1.raw() + + expect(h1Raw.a).to.include('string') + expect(h1Raw.b).to.include('1,2') + expect(h1Raw.c).to.include('') + expect(h1Raw.d).to.include('') + expect(h1Raw.e).to.include('1') + expect(h1Raw.f).to.include('1,2') + expect(h1Raw.g).to.include('[object Object]') + expect(h1Raw.h).to.include('undefined') + expect(h1Raw.i).to.include('null') + expect(h1Raw.j).to.include('NaN') + expect(h1Raw.k).to.include('true') + expect(h1Raw.l).to.include('false') + expect(h1Raw.m).to.include('test') + expect(h1Raw.n).to.include('1,2') + expect(h1Raw.n).to.include('3,4') + + expect(h1Raw.z).to.be.undefined + }) + + xit('should wrap headers', () => { + const h1 = new Headers({ + a: '1' + }) + const h1Raw = h1.raw() + + const h2 = new Headers(h1) + h2.set('b', '1') + const h2Raw = h2.raw() + + const h3 = new Headers(h2) + h3.append('a', '2') + const h3Raw = h3.raw() + + expect(h1Raw.a).to.include('1') + expect(h1Raw.a).to.not.include('2') + + expect(h2Raw.a).to.include('1') + expect(h2Raw.a).to.not.include('2') + expect(h2Raw.b).to.include('1') + + expect(h3Raw.a).to.include('1') + expect(h3Raw.a).to.include('2') + expect(h3Raw.b).to.include('1') + }) + + it('should accept headers as an iterable of tuples', () => { + let headers + + headers = new Headers([ + ['a', '1'], + ['b', '2'], + ['a', '3'] + ]) + expect(headers.get('a')).to.equal('1, 3') + expect(headers.get('b')).to.equal('2') + + headers = new Headers([ + new Set(['a', '1']), + ['b', '2'], + new Map([['a', null], ['3', null]]).keys() + ]) + expect(headers.get('a')).to.equal('1, 3') + expect(headers.get('b')).to.equal('2') + + headers = new Headers(new Map([ + ['a', '1'], + ['b', '2'] + ])) + expect(headers.get('a')).to.equal('1') + expect(headers.get('b')).to.equal('2') + }) + + it('should throw a TypeError if non-tuple exists in a headers initializer', () => { + expect(() => new Headers([['b', '2', 'huh?']])).to.throw(TypeError) + expect(() => new Headers(['b2'])).to.throw(TypeError) + expect(() => new Headers('b2')).to.throw(TypeError) + expect(() => new Headers({ [Symbol.iterator]: 42 })).to.throw(TypeError) + }) + + xit('should use a custom inspect function', () => { + const headers = new Headers([ + ['Host', 'thehost'], + ['Host', 'notthehost'], + ['a', '1'], + ['b', '2'], + ['a', '3'] + ]) + + // eslint-disable-next-line quotes + expect(format(headers)).to.equal("{ a: [ '1', '3' ], b: '2', host: 'thehost' }") + }) +}) diff --git a/test/node-fetch/main.js b/test/node-fetch/main.js new file mode 100644 index 0000000..358a969 --- /dev/null +++ b/test/node-fetch/main.js @@ -0,0 +1,1661 @@ +/* eslint no-unused-expressions: "off" */ +/* globals AbortController */ + +// Test tools +const zlib = require('zlib') +const stream = require('stream') +const vm = require('vm') +const chai = require('chai') +const crypto = require('crypto') +const chaiPromised = require('chai-as-promised') +const chaiIterator = require('chai-iterator') +const chaiString = require('chai-string') +const delay = require('delay') +const { Blob } = require('buffer') + +const { + fetch, + Headers, + Request, + FormData, + Response, + setGlobalDispatcher, + Agent +} = require('../../index.js') +const HeadersOrig = require('../../lib/fetch/headers.js').Headers +const RequestOrig = require('../../lib/fetch/request.js').Request +const ResponseOrig = require('../../lib/fetch/response.js').Response +const TestServer = require('./utils/server.js') +const chaiTimeout = require('./utils/chai-timeout.js') +const { ReadableStream } = require('stream/web') + +function isNodeLowerThan (version) { + return !~process.version.localeCompare(version, undefined, { numeric: true }) +} + +const { + Uint8Array: VMUint8Array +} = vm.runInNewContext('this') + +chai.use(chaiPromised) +chai.use(chaiIterator) +chai.use(chaiString) +chai.use(chaiTimeout) +const { expect } = chai + +describe('node-fetch', () => { + const local = new TestServer() + let base + + before(async () => { + await local.start() + setGlobalDispatcher(new Agent({ + connect: { + rejectUnauthorized: false + } + })) + base = `http://${local.hostname}:${local.port}/` + }) + + after(async () => { + return local.stop() + }) + + it('should return a promise', () => { + const url = `${base}hello` + const p = fetch(url) + expect(p).to.be.an.instanceof(Promise) + expect(p).to.have.property('then') + }) + + it('should expose Headers, Response and Request constructors', () => { + expect(Headers).to.equal(HeadersOrig) + expect(Response).to.equal(ResponseOrig) + expect(Request).to.equal(RequestOrig) + }) + + it('should support proper toString output for Headers, Response and Request objects', () => { + expect(new Headers().toString()).to.equal('[object Headers]') + expect(new Response().toString()).to.equal('[object Response]') + expect(new Request(base).toString()).to.equal('[object Request]') + }) + + it('should reject with error if url is protocol relative', () => { + const url = '//example.com/' + return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError) + }) + + it('should reject with error if url is relative path', () => { + const url = '/some/path' + return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError) + }) + + it('should reject with error if protocol is unsupported', () => { + const url = 'ftp://example.com/' + return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError) + }) + + it('should reject with error on network failure', function () { + this.timeout(5000) + const url = 'http://localhost:50000/' + return expect(fetch(url)).to.eventually.be.rejected + .and.be.an.instanceOf(TypeError) + }) + + it('should resolve into response', () => { + const url = `${base}hello` + return fetch(url).then(res => { + expect(res).to.be.an.instanceof(Response) + expect(res.headers).to.be.an.instanceof(Headers) + expect(res.body).to.be.an.instanceof(ReadableStream) + expect(res.bodyUsed).to.be.false + + expect(res.url).to.equal(url) + expect(res.ok).to.be.true + expect(res.status).to.equal(200) + expect(res.statusText).to.equal('OK') + }) + }) + + it('Response.redirect should resolve into response', () => { + const res = Response.redirect('http://localhost') + expect(res).to.be.an.instanceof(Response) + expect(res.headers).to.be.an.instanceof(Headers) + expect(res.headers.get('location')).to.equal('http://localhost/') + expect(res.status).to.equal(302) + }) + + it('Response.redirect /w invalid url should fail', () => { + expect(() => { + Response.redirect('localhost') + }).to.throw() + }) + + it('Response.redirect /w invalid status should fail', () => { + expect(() => { + Response.redirect('http://localhost', 200) + }).to.throw() + }) + + it('should accept plain text response', () => { + const url = `${base}plain` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(res.bodyUsed).to.be.true + expect(result).to.be.a('string') + expect(result).to.equal('text') + }) + }) + }) + + it('should accept html response (like plain text)', () => { + const url = `${base}html` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/html') + return res.text().then(result => { + expect(res.bodyUsed).to.be.true + expect(result).to.be.a('string') + expect(result).to.equal('') + }) + }) + }) + + it('should accept json response', () => { + const url = `${base}json` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('application/json') + return res.json().then(result => { + expect(res.bodyUsed).to.be.true + expect(result).to.be.an('object') + expect(result).to.deep.equal({ name: 'value' }) + }) + }) + }) + + it('should send request with custom headers', () => { + const url = `${base}inspect` + const options = { + headers: { 'x-custom-header': 'abc' } + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.headers['x-custom-header']).to.equal('abc') + }) + }) + + it('should send request with custom headers array', () => { + const url = `${base}inspect` + const options = { + headers: { 'x-custom-header': ['abc'] } + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.headers['x-custom-header']).to.equal('abc') + }) + }) + + it('should send request with multi-valued headers', () => { + const url = `${base}inspect` + const options = { + headers: { 'x-custom-header': ['abc', '123'] } + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.headers['x-custom-header']).to.equal('abc,123') + }) + }) + + it('should accept headers instance', () => { + const url = `${base}inspect` + const options = { + headers: new Headers({ 'x-custom-header': 'abc' }) + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.headers['x-custom-header']).to.equal('abc') + }) + }) + + it('should follow redirect code 301', () => { + const url = `${base}redirect/301` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + expect(res.ok).to.be.true + }) + }) + + it('should follow redirect code 302', () => { + const url = `${base}redirect/302` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should follow redirect code 303', () => { + const url = `${base}redirect/303` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should follow redirect code 307', () => { + const url = `${base}redirect/307` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should follow redirect code 308', () => { + const url = `${base}redirect/308` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should follow redirect chain', () => { + const url = `${base}redirect/chain` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should follow POST request redirect code 301 with GET', () => { + const url = `${base}redirect/301` + const options = { + method: 'POST', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(result => { + expect(result.method).to.equal('GET') + expect(result.body).to.equal('') + }) + }) + }) + + it('should follow PATCH request redirect code 301 with PATCH', () => { + const url = `${base}redirect/301` + const options = { + method: 'PATCH', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(res => { + expect(res.method).to.equal('PATCH') + expect(res.body).to.equal('a=1') + }) + }) + }) + + it('should follow POST request redirect code 302 with GET', () => { + const url = `${base}redirect/302` + const options = { + method: 'POST', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(result => { + expect(result.method).to.equal('GET') + expect(result.body).to.equal('') + }) + }) + }) + + it('should follow PATCH request redirect code 302 with PATCH', () => { + const url = `${base}redirect/302` + const options = { + method: 'PATCH', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(res => { + expect(res.method).to.equal('PATCH') + expect(res.body).to.equal('a=1') + }) + }) + }) + + it('should follow redirect code 303 with GET', () => { + const url = `${base}redirect/303` + const options = { + method: 'PUT', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(result => { + expect(result.method).to.equal('GET') + expect(result.body).to.equal('') + }) + }) + }) + + it('should follow PATCH request redirect code 307 with PATCH', () => { + const url = `${base}redirect/307` + const options = { + method: 'PATCH', + body: 'a=1' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + return res.json().then(result => { + expect(result.method).to.equal('PATCH') + expect(result.body).to.equal('a=1') + }) + }) + }) + + it('should not follow non-GET redirect if body is a readable stream', () => { + const url = `${base}redirect/307` + const options = { + method: 'PATCH', + body: stream.Readable.from('tada') + } + return expect(fetch(url, options)).to.eventually.be.rejected + .and.be.an.instanceOf(TypeError) + }) + + it('should obey maximum redirect, reject case', () => { + const url = `${base}redirect/chain/20` + return expect(fetch(url)).to.eventually.be.rejected + .and.be.an.instanceOf(TypeError) + }) + + it('should obey redirect chain, resolve case', () => { + const url = `${base}redirect/chain/19` + return fetch(url).then(res => { + expect(res.url).to.equal(`${base}inspect`) + expect(res.status).to.equal(200) + }) + }) + + it('should support redirect mode, error flag', () => { + const url = `${base}redirect/301` + const options = { + redirect: 'error' + } + return expect(fetch(url, options)).to.eventually.be.rejected + .and.be.an.instanceOf(TypeError) + }) + + it('should support redirect mode, manual flag when there is no redirect', () => { + const url = `${base}hello` + const options = { + redirect: 'manual' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(url) + expect(res.status).to.equal(200) + expect(res.headers.get('location')).to.be.null + }) + }) + + it('should follow redirect code 301 and keep existing headers', () => { + const url = `${base}redirect/301` + const options = { + headers: new Headers({ 'x-custom-header': 'abc' }) + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(`${base}inspect`) + return res.json() + }).then(res => { + expect(res.headers['x-custom-header']).to.equal('abc') + }) + }) + + it('should treat broken redirect as ordinary response (follow)', () => { + const url = `${base}redirect/no-location` + return fetch(url).then(res => { + expect(res.url).to.equal(url) + expect(res.status).to.equal(301) + expect(res.headers.get('location')).to.be.null + }) + }) + + it('should treat broken redirect as ordinary response (manual)', () => { + const url = `${base}redirect/no-location` + const options = { + redirect: 'manual' + } + return fetch(url, options).then(res => { + expect(res.url).to.equal(url) + expect(res.status).to.equal(301) + expect(res.headers.get('location')).to.be.null + }) + }) + + it('should throw a TypeError on an invalid redirect option', () => { + const url = `${base}redirect/301` + const options = { + redirect: 'foobar' + } + return fetch(url, options).then(() => { + expect.fail() + }, error => { + expect(error).to.be.an.instanceOf(TypeError) + }) + }) + + it('should set redirected property on response when redirect', () => { + const url = `${base}redirect/301` + return fetch(url).then(res => { + expect(res.redirected).to.be.true + }) + }) + + it('should not set redirected property on response without redirect', () => { + const url = `${base}hello` + return fetch(url).then(res => { + expect(res.redirected).to.be.false + }) + }) + + it('should handle client-error response', () => { + const url = `${base}error/400` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + expect(res.status).to.equal(400) + expect(res.statusText).to.equal('Bad Request') + expect(res.ok).to.be.false + return res.text().then(result => { + expect(res.bodyUsed).to.be.true + expect(result).to.be.a('string') + expect(result).to.equal('client error') + }) + }) + }) + + it('should handle server-error response', () => { + const url = `${base}error/500` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + expect(res.status).to.equal(500) + expect(res.statusText).to.equal('Internal Server Error') + expect(res.ok).to.be.false + return res.text().then(result => { + expect(res.bodyUsed).to.be.true + expect(result).to.be.a('string') + expect(result).to.equal('server error') + }) + }) + }) + + it('should handle network-error response', () => { + const url = `${base}error/reset` + return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError) + }) + + it('should handle network-error partial response', () => { + const url = `${base}error/premature` + return fetch(url).then(res => { + expect(res.status).to.equal(200) + expect(res.ok).to.be.true + return expect(res.text()).to.eventually.be.rejectedWith(Error) + }) + }) + + it('should handle network-error in chunked response async iterator', () => { + const url = `${base}error/premature/chunked` + return fetch(url).then(res => { + expect(res.status).to.equal(200) + expect(res.ok).to.be.true + + const read = async body => { + const chunks = [] + for await (const chunk of body) { + chunks.push(chunk) + } + + return chunks + } + + return expect(read(res.body)) + .to.eventually.be.rejectedWith(Error) + }) + }) + + it('should handle network-error in chunked response in consumeBody', () => { + const url = `${base}error/premature/chunked` + return fetch(url).then(res => { + expect(res.status).to.equal(200) + expect(res.ok).to.be.true + + return expect(res.text()).to.eventually.be.rejectedWith(Error) + }) + }) + + it('should handle DNS-error response', () => { + const url = 'http://domain.invalid' + return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError) + }) + + it('should reject invalid json response', () => { + const url = `${base}error/json` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('application/json') + return expect(res.json()).to.eventually.be.rejectedWith(Error) + }) + }) + + it('should handle response with no status text', () => { + const url = `${base}no-status-text` + return fetch(url).then(res => { + expect(res.statusText).to.equal('') + }) + }) + + it('should handle no content response', () => { + const url = `${base}no-content` + return fetch(url).then(res => { + expect(res.status).to.equal(204) + expect(res.statusText).to.equal('No Content') + expect(res.ok).to.be.true + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.be.empty + }) + }) + }) + + it('should reject when trying to parse no content response as json', () => { + const url = `${base}no-content` + return fetch(url).then(res => { + expect(res.status).to.equal(204) + expect(res.statusText).to.equal('No Content') + expect(res.ok).to.be.true + return expect(res.json()).to.eventually.be.rejectedWith(Error) + }) + }) + + it('should handle no content response with gzip encoding', () => { + const url = `${base}no-content/gzip` + return fetch(url).then(res => { + expect(res.status).to.equal(204) + expect(res.statusText).to.equal('No Content') + expect(res.headers.get('content-encoding')).to.equal('gzip') + expect(res.ok).to.be.true + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.be.empty + }) + }) + }) + + it('should handle not modified response', () => { + const url = `${base}not-modified` + return fetch(url).then(res => { + expect(res.status).to.equal(304) + expect(res.statusText).to.equal('Not Modified') + expect(res.ok).to.be.false + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.be.empty + }) + }) + }) + + it('should handle not modified response with gzip encoding', () => { + const url = `${base}not-modified/gzip` + return fetch(url).then(res => { + expect(res.status).to.equal(304) + expect(res.statusText).to.equal('Not Modified') + expect(res.headers.get('content-encoding')).to.equal('gzip') + expect(res.ok).to.be.false + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.be.empty + }) + }) + }) + + it('should decompress gzip response', () => { + const url = `${base}gzip` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + }) + }) + + it('should decompress slightly invalid gzip response', async () => { + const url = `${base}gzip-truncated` + const res = await fetch(url) + expect(res.headers.get('content-type')).to.equal('text/plain') + const result = await res.text() + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + + it('should decompress deflate response', () => { + const url = `${base}deflate` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + }) + }) + + xit('should decompress deflate raw response from old apache server', () => { + const url = `${base}deflate-raw` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + }) + }) + + it('should decompress brotli response', function () { + if (typeof zlib.createBrotliDecompress !== 'function') { + this.skip() + } + + const url = `${base}brotli` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + }) + }) + + it('should handle no content response with brotli encoding', function () { + if (typeof zlib.createBrotliDecompress !== 'function') { + this.skip() + } + + const url = `${base}no-content/brotli` + return fetch(url).then(res => { + expect(res.status).to.equal(204) + expect(res.statusText).to.equal('No Content') + expect(res.headers.get('content-encoding')).to.equal('br') + expect(res.ok).to.be.true + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.be.empty + }) + }) + }) + + it('should skip decompression if unsupported', () => { + const url = `${base}sdch` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('fake sdch string') + }) + }) + }) + + it('should skip decompression if unsupported codings', () => { + const url = `${base}multiunsupported` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('multiunsupported') + }) + }) + }) + + it('should decompress multiple coding', () => { + const url = `${base}multisupported` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(result => { + expect(result).to.be.a('string') + expect(result).to.equal('hello world') + }) + }) + }) + + it('should reject if response compression is invalid', () => { + const url = `${base}invalid-content-encoding` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return expect(res.text()).to.eventually.be.rejected + }) + }) + + it('should handle errors on the body stream even if it is not used', done => { + const url = `${base}invalid-content-encoding` + fetch(url) + .then(res => { + expect(res.status).to.equal(200) + }) + .catch(() => {}) + .then(() => { + // Wait a few ms to see if a uncaught error occurs + setTimeout(() => { + done() + }, 20) + }) + }) + + it('should collect handled errors on the body stream to reject if the body is used later', () => { + const url = `${base}invalid-content-encoding` + return fetch(url).then(delay(20)).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return expect(res.text()).to.eventually.be.rejected + }) + }) + + it('should not overwrite existing accept-encoding header when auto decompression is true', () => { + const url = `${base}inspect` + const options = { + compress: true, + headers: { + 'Accept-Encoding': 'gzip' + } + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.headers['accept-encoding']).to.equal('gzip') + }) + }) + + describe('AbortController', () => { + let controller + + beforeEach(() => { + controller = new AbortController() + }) + + it('should support request cancellation with signal', () => { + const fetches = [ + fetch( + `${base}timeout`, + { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + body: JSON.stringify({ hello: 'world' }) + } + } + ) + ] + + controller.abort() + + return Promise.all(fetches.map(fetched => expect(fetched) + .to.eventually.be.rejected + .and.be.an.instanceOf(Error) + .and.have.property('name', 'AbortError') + )) + }) + + it('should support multiple request cancellation with signal', () => { + const fetches = [ + fetch(`${base}timeout`, { signal: controller.signal }), + fetch( + `${base}timeout`, + { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + body: JSON.stringify({ hello: 'world' }) + } + } + ) + ] + + controller.abort() + + return Promise.all(fetches.map(fetched => expect(fetched) + .to.eventually.be.rejected + .and.be.an.instanceOf(Error) + .and.have.property('name', 'AbortError') + )) + }) + + it('should reject immediately if signal has already been aborted', () => { + const url = `${base}timeout` + const options = { + signal: controller.signal + } + controller.abort() + const fetched = fetch(url, options) + return expect(fetched).to.eventually.be.rejected + .and.be.an.instanceOf(Error) + .and.have.property('name', 'AbortError') + }) + + it('should allow redirects to be aborted', () => { + const request = new Request(`${base}redirect/slow`, { + signal: controller.signal + }) + setTimeout(() => { + controller.abort() + }, 20) + return expect(fetch(request)).to.be.eventually.rejected + .and.be.an.instanceOf(Error) + .and.have.property('name', 'AbortError') + }) + + it('should allow redirected response body to be aborted', () => { + const request = new Request(`${base}redirect/slow-stream`, { + signal: controller.signal + }) + return expect(fetch(request).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + const result = res.text() + controller.abort() + return result + })).to.be.eventually.rejected + .and.be.an.instanceOf(Error) + .and.have.property('name', 'AbortError') + }) + + it('should reject response body with AbortError when aborted before stream has been read completely', () => { + return expect(fetch( + `${base}slow`, + { signal: controller.signal } + )) + .to.eventually.be.fulfilled + .then(res => { + const promise = res.text() + controller.abort() + return expect(promise) + .to.eventually.be.rejected + .and.be.an.instanceof(Error) + .and.have.property('name', 'AbortError') + }) + }) + + it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', () => { + return expect(fetch( + `${base}slow`, + { signal: controller.signal } + )) + .to.eventually.be.fulfilled + .then(res => { + controller.abort() + return expect(res.text()) + .to.eventually.be.rejected + .and.be.an.instanceof(Error) + .and.have.property('name', 'AbortError') + }) + }) + }) + + it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', () => { + return Promise.all([ + expect(fetch(`${base}inspect`, { signal: {} })) + .to.be.eventually.rejected + .and.be.an.instanceof(TypeError), + expect(fetch(`${base}inspect`, { signal: '' })) + .to.be.eventually.rejected + .and.be.an.instanceof(TypeError), + expect(fetch(`${base}inspect`, { signal: Object.create(null) })) + .to.be.eventually.rejected + .and.be.an.instanceof(TypeError) + ]) + }) + + it('should gracefully handle a null signal', () => { + return fetch(`${base}hello`, { signal: null }).then(res => { + return expect(res.ok).to.be.true + }) + }) + + it('should allow setting User-Agent', () => { + const url = `${base}inspect` + const options = { + headers: { + 'user-agent': 'faked' + } + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.headers['user-agent']).to.equal('faked') + }) + }) + + it('should set default Accept header', () => { + const url = `${base}inspect` + fetch(url).then(res => res.json()).then(res => { + expect(res.headers.accept).to.equal('*/*') + }) + }) + + it('should allow setting Accept header', () => { + const url = `${base}inspect` + const options = { + headers: { + accept: 'application/json' + } + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.headers.accept).to.equal('application/json') + }) + }) + + it('should allow POST request', () => { + const url = `${base}inspect` + const options = { + method: 'POST' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('0') + }) + }) + + it('should allow POST request with string body', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: 'a=1' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8') + expect(res.headers['content-length']).to.equal('3') + }) + }) + + it('should allow POST request with buffer body', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: Buffer.from('a=1', 'utf-8') + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('3') + }) + }) + + it('should allow POST request with ArrayBuffer body', () => { + const encoder = new TextEncoder() + const url = `${base}inspect` + const options = { + method: 'POST', + body: encoder.encode('Hello, world!\n').buffer + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('Hello, world!\n') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('14') + }) + }) + + it('should allow POST request with ArrayBuffer body from a VM context', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: new VMUint8Array(Buffer.from('Hello, world!\n')).buffer + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('Hello, world!\n') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('14') + }) + }) + + it('should allow POST request with ArrayBufferView (Uint8Array) body', () => { + const encoder = new TextEncoder() + const url = `${base}inspect` + const options = { + method: 'POST', + body: encoder.encode('Hello, world!\n') + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('Hello, world!\n') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('14') + }) + }) + + it('should allow POST request with ArrayBufferView (BigUint64Array) body', () => { + const encoder = new TextEncoder() + const url = `${base}inspect` + const options = { + method: 'POST', + body: new BigUint64Array(encoder.encode('0123456789abcdef').buffer) + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('0123456789abcdef') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('16') + }) + }) + + it('should allow POST request with ArrayBufferView (DataView) body', () => { + const encoder = new TextEncoder() + const url = `${base}inspect` + const options = { + method: 'POST', + body: new DataView(encoder.encode('Hello, world!\n').buffer) + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('Hello, world!\n') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('14') + }) + }) + + it('should allow POST request with ArrayBufferView (Uint8Array) body from a VM context', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: new VMUint8Array(Buffer.from('Hello, world!\n')) + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('Hello, world!\n') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('14') + }) + }) + + it('should allow POST request with ArrayBufferView (Uint8Array, offset, length) body', () => { + const encoder = new TextEncoder() + const url = `${base}inspect` + const options = { + method: 'POST', + body: encoder.encode('Hello, world!\n').subarray(7, 13) + } + return fetch(url, options).then(res => res.json()).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('world!') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('6') + }) + }) + + it('should allow POST request with blob body without type', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: new Blob(['a=1']) + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.be.undefined + // expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.equal('3') + }) + }) + + it('should allow POST request with blob body with type', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: new Blob(['a=1'], { + type: 'text/plain;charset=UTF-8' + }) + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-type']).to.equal('text/plain;charset=utf-8') + expect(res.headers['content-length']).to.equal('3') + }) + }) + + it('should allow POST request with readable stream as body', () => { + const url = `${base}inspect` + const options = { + method: 'POST', + body: stream.Readable.from('a=1'), + duplex: 'half' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.equal('chunked') + expect(res.headers['content-type']).to.be.undefined + expect(res.headers['content-length']).to.be.undefined + }) + }) + + it('should allow POST request with object body', () => { + const url = `${base}inspect` + // Note that fetch simply calls tostring on an object + const options = { + method: 'POST', + body: { a: 1 } + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.body).to.equal('[object Object]') + expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8') + expect(res.headers['content-length']).to.equal('15') + }) + }) + + it('should allow POST request with form-data as body', () => { + const form = new FormData() + form.append('a', '1') + + const url = `${base}multipart` + const options = { + method: 'POST', + body: form + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.headers['content-type']).to.startWith('multipart/form-data; boundary=') + expect(res.body).to.equal('a=1') + }) + }) + + it('constructing a Response with URLSearchParams as body should have a Content-Type', () => { + const parameters = new URLSearchParams() + const res = new Response(parameters) + res.headers.get('Content-Type') + expect(res.headers.get('Content-Type')).to.equal('application/x-www-form-urlencoded;charset=UTF-8') + }) + + it('constructing a Request with URLSearchParams as body should have a Content-Type', () => { + const parameters = new URLSearchParams() + const request = new Request(base, { method: 'POST', body: parameters }) + expect(request.headers.get('Content-Type')).to.equal('application/x-www-form-urlencoded;charset=UTF-8') + }) + + it('Reading a body with URLSearchParams should echo back the result', () => { + const parameters = new URLSearchParams() + parameters.append('a', '1') + return new Response(parameters).text().then(text => { + expect(text).to.equal('a=1') + }) + }) + + // Body should been cloned... + it('constructing a Request/Response with URLSearchParams and mutating it should not affected body', () => { + const parameters = new URLSearchParams() + const request = new Request(`${base}inspect`, { method: 'POST', body: parameters }) + parameters.append('a', '1') + return request.text().then(text => { + expect(text).to.equal('') + }) + }) + + it('should allow POST request with URLSearchParams as body', () => { + const parameters = new URLSearchParams() + parameters.append('a', '1') + + const url = `${base}inspect` + const options = { + method: 'POST', + body: parameters + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.headers['content-type']).to.equal('application/x-www-form-urlencoded;charset=UTF-8') + expect(res.headers['content-length']).to.equal('3') + expect(res.body).to.equal('a=1') + }) + }) + + it('should still recognize URLSearchParams when extended', () => { + class CustomSearchParameters extends URLSearchParams {} + const parameters = new CustomSearchParameters() + parameters.append('a', '1') + + const url = `${base}inspect` + const options = { + method: 'POST', + body: parameters + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('POST') + expect(res.headers['content-type']).to.equal('application/x-www-form-urlencoded;charset=UTF-8') + expect(res.headers['content-length']).to.equal('3') + expect(res.body).to.equal('a=1') + }) + }) + + it('should allow PUT request', () => { + const url = `${base}inspect` + const options = { + method: 'PUT', + body: 'a=1' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('PUT') + expect(res.body).to.equal('a=1') + }) + }) + + it('should allow DELETE request', () => { + const url = `${base}inspect` + const options = { + method: 'DELETE' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('DELETE') + }) + }) + + it('should allow DELETE request with string body', () => { + const url = `${base}inspect` + const options = { + method: 'DELETE', + body: 'a=1' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('DELETE') + expect(res.body).to.equal('a=1') + expect(res.headers['transfer-encoding']).to.be.undefined + expect(res.headers['content-length']).to.equal('3') + }) + }) + + it('should allow PATCH request', () => { + const url = `${base}inspect` + const options = { + method: 'PATCH', + body: 'a=1' + } + return fetch(url, options).then(res => { + return res.json() + }).then(res => { + expect(res.method).to.equal('PATCH') + expect(res.body).to.equal('a=1') + }) + }) + + it('should allow HEAD request', () => { + const url = `${base}hello` + const options = { + method: 'HEAD' + } + return fetch(url, options).then(res => { + expect(res.status).to.equal(200) + expect(res.statusText).to.equal('OK') + expect(res.headers.get('content-type')).to.equal('text/plain') + // expect(res.body).to.be.an.instanceof(stream.Transform) + return res.text() + }).then(text => { + expect(text).to.equal('') + }) + }) + + it('should allow HEAD request with content-encoding header', () => { + const url = `${base}error/404` + const options = { + method: 'HEAD' + } + return fetch(url, options).then(res => { + expect(res.status).to.equal(404) + expect(res.headers.get('content-encoding')).to.equal('gzip') + return res.text() + }).then(text => { + expect(text).to.equal('') + }) + }) + + it('should allow OPTIONS request', () => { + const url = `${base}options` + const options = { + method: 'OPTIONS' + } + return fetch(url, options).then(res => { + expect(res.status).to.equal(200) + expect(res.statusText).to.equal('OK') + expect(res.headers.get('allow')).to.equal('GET, HEAD, OPTIONS') + // expect(res.body).to.be.an.instanceof(stream.Transform) + }) + }) + + it('should reject decoding body twice', () => { + const url = `${base}plain` + return fetch(url).then(res => { + expect(res.headers.get('content-type')).to.equal('text/plain') + return res.text().then(() => { + expect(res.bodyUsed).to.be.true + return expect(res.text()).to.eventually.be.rejectedWith(Error) + }) + }) + }) + + it('should allow cloning a json response and log it as text response', () => { + const url = `${base}json` + return fetch(url).then(res => { + const r1 = res.clone() + return Promise.all([res.json(), r1.text()]).then(results => { + expect(results[0]).to.deep.equal({ name: 'value' }) + expect(results[1]).to.equal('{"name":"value"}') + }) + }) + }) + + it('should allow cloning a json response, and then log it as text response', () => { + const url = `${base}json` + return fetch(url).then(res => { + const r1 = res.clone() + return res.json().then(result => { + expect(result).to.deep.equal({ name: 'value' }) + return r1.text().then(result => { + expect(result).to.equal('{"name":"value"}') + }) + }) + }) + }) + + it('should allow cloning a json response, first log as text response, then return json object', () => { + const url = `${base}json` + return fetch(url).then(res => { + const r1 = res.clone() + return r1.text().then(result => { + expect(result).to.equal('{"name":"value"}') + return res.json().then(result => { + expect(result).to.deep.equal({ name: 'value' }) + }) + }) + }) + }) + + it('should not allow cloning a response after its been used', () => { + const url = `${base}hello` + return fetch(url).then(res => + res.text().then(() => { + expect(() => { + res.clone() + }).to.throw(Error) + }) + ) + }) + + xit('should timeout on cloning response without consuming one of the streams when the second packet size is equal default highWaterMark', function () { + this.timeout(300) + const url = local.mockState(res => { + // Observed behavior of TCP packets splitting: + // - response body size <= 65438 → single packet sent + // - response body size > 65438 → multiple packets sent + // Max TCP packet size is 64kB (http://stackoverflow.com/a/2614188/5763764), + // but first packet probably transfers more than the response body. + const firstPacketMaxSize = 65438 + const secondPacketSize = 16 * 1024 // = defaultHighWaterMark + res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize)) + }) + return expect( + fetch(url).then(res => res.clone().buffer()) + ).to.timeout + }) + + xit('should timeout on cloning response without consuming one of the streams when the second packet size is equal custom highWaterMark', function () { + this.timeout(300) + const url = local.mockState(res => { + const firstPacketMaxSize = 65438 + const secondPacketSize = 10 + res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize)) + }) + return expect( + fetch(url, { highWaterMark: 10 }).then(res => res.clone().buffer()) + ).to.timeout + }) + + xit('should not timeout on cloning response without consuming one of the streams when the second packet size is less than default highWaterMark', function () { + // TODO: fix test. + if (!isNodeLowerThan('v16.0.0')) { + this.skip() + } + + this.timeout(300) + const url = local.mockState(res => { + const firstPacketMaxSize = 65438 + const secondPacketSize = 16 * 1024 // = defaultHighWaterMark + res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize - 1)) + }) + return expect( + fetch(url).then(res => res.clone().buffer()) + ).not.to.timeout + }) + + xit('should not timeout on cloning response without consuming one of the streams when the second packet size is less than custom highWaterMark', function () { + // TODO: fix test. + if (!isNodeLowerThan('v16.0.0')) { + this.skip() + } + + this.timeout(300) + const url = local.mockState(res => { + const firstPacketMaxSize = 65438 + const secondPacketSize = 10 + res.end(crypto.randomBytes(firstPacketMaxSize + secondPacketSize - 1)) + }) + return expect( + fetch(url, { highWaterMark: 10 }).then(res => res.clone().buffer()) + ).not.to.timeout + }) + + xit('should not timeout on cloning response without consuming one of the streams when the response size is double the custom large highWaterMark - 1', function () { + // TODO: fix test. + if (!isNodeLowerThan('v16.0.0')) { + this.skip() + } + + this.timeout(300) + const url = local.mockState(res => { + res.end(crypto.randomBytes((2 * 512 * 1024) - 1)) + }) + return expect( + fetch(url, { highWaterMark: 512 * 1024 }).then(res => res.clone().buffer()) + ).not.to.timeout + }) + + xit('should allow get all responses of a header', () => { + // TODO: fix test. + const url = `${base}cookie` + return fetch(url).then(res => { + const expected = 'a=1, b=1' + expect(res.headers.get('set-cookie')).to.equal(expected) + expect(res.headers.get('Set-Cookie')).to.equal(expected) + }) + }) + + it('should support fetch with Request instance', () => { + const url = `${base}hello` + const request = new Request(url) + return fetch(request).then(res => { + expect(res.url).to.equal(url) + expect(res.ok).to.be.true + expect(res.status).to.equal(200) + }) + }) + + it('should support fetch with Node.js URL object', () => { + const url = `${base}hello` + const urlObject = new URL(url) + const request = new Request(urlObject) + return fetch(request).then(res => { + expect(res.url).to.equal(url) + expect(res.ok).to.be.true + expect(res.status).to.equal(200) + }) + }) + + it('should support fetch with WHATWG URL object', () => { + const url = `${base}hello` + const urlObject = new URL(url) + const request = new Request(urlObject) + return fetch(request).then(res => { + expect(res.url).to.equal(url) + expect(res.ok).to.be.true + expect(res.status).to.equal(200) + }) + }) + + it('if params are given, do not modify anything', () => { + const url = `${base}question?a=1` + const urlObject = new URL(url) + const request = new Request(urlObject) + return fetch(request).then(res => { + expect(res.url).to.equal(url) + expect(res.ok).to.be.true + expect(res.status).to.equal(200) + }) + }) + + it('should support reading blob as text', () => { + return new Response('hello') + .blob() + .then(blob => blob.text()) + .then(body => { + expect(body).to.equal('hello') + }) + }) + + it('should support reading blob as arrayBuffer', () => { + return new Response('hello') + .blob() + .then(blob => blob.arrayBuffer()) + .then(ab => { + const string = String.fromCharCode.apply(null, new Uint8Array(ab)) + expect(string).to.equal('hello') + }) + }) + + it('should support blob round-trip', () => { + const url = `${base}hello` + + let length + let type + + return fetch(url).then(res => res.blob()).then(async blob => { + const url = `${base}inspect` + length = blob.size + type = blob.type + return fetch(url, { + method: 'POST', + body: blob + }) + }).then(res => res.json()).then(({ body, headers }) => { + expect(body).to.equal('world') + expect(headers['content-type']).to.equal(type) + expect(headers['content-length']).to.equal(String(length)) + }) + }) + + it('should support overwrite Request instance', () => { + const url = `${base}inspect` + const request = new Request(url, { + method: 'POST', + headers: { + a: '1' + } + }) + return fetch(request, { + method: 'GET', + headers: { + a: '2' + } + }).then(res => { + return res.json() + }).then(body => { + expect(body.method).to.equal('GET') + expect(body.headers.a).to.equal('2') + }) + }) + + it('should support http request', function () { + this.timeout(5000) + const url = 'https://github.com/' + const options = { + method: 'HEAD' + } + return fetch(url, options).then(res => { + expect(res.status).to.equal(200) + expect(res.ok).to.be.true + }) + }) + + it('should encode URLs as UTF-8', async () => { + const url = `${base}möbius` + const res = await fetch(url) + expect(res.url).to.equal(`${base}m%C3%B6bius`) + }) + + it('should allow manual redirect handling', function () { + this.timeout(5000) + const url = `${base}redirect/302` + const options = { + redirect: 'manual' + } + return fetch(url, options).then(res => { + expect(res.status).to.equal(302) + expect(res.url).to.equal(url) + expect(res.type).to.equal('basic') + expect(res.headers.get('Location')).to.equal('/inspect') + expect(res.ok).to.be.false + }) + }) +}) diff --git a/test/node-fetch/mock.js b/test/node-fetch/mock.js new file mode 100644 index 0000000..a53f464 --- /dev/null +++ b/test/node-fetch/mock.js @@ -0,0 +1,112 @@ +/* eslint no-unused-expressions: "off" */ + +// Test tools +const chai = require('chai') + +const { + fetch, + MockAgent, + setGlobalDispatcher, + Headers +} = require('../../index.js') + +const { expect } = chai + +describe('node-fetch with MockAgent', () => { + it('should match the url', async () => { + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool + .intercept({ + path: '/test', + method: 'GET' + }) + .reply(200, { success: true }) + .persist() + + const res = await fetch('http://localhost:3000/test', { + method: 'GET' + }) + + expect(res.status).to.equal(200) + expect(await res.json()).to.deep.equal({ success: true }) + }) + + it('should match the body', async () => { + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool + .intercept({ + path: '/test', + method: 'POST', + body: (value) => { + return value === 'request body' + } + }) + .reply(200, { success: true }) + .persist() + + const res = await fetch('http://localhost:3000/test', { + method: 'POST', + body: 'request body' + }) + + expect(res.status).to.equal(200) + expect(await res.json()).to.deep.equal({ success: true }) + }) + + it('should match the headers', async () => { + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool + .intercept({ + path: '/test', + method: 'GET', + headers: (h) => { + return h['user-agent'] === 'undici' + } + }) + .reply(200, { success: true }) + .persist() + + const res = await fetch('http://localhost:3000/test', { + method: 'GET', + headers: new Headers({ 'User-Agent': 'undici' }) + }) + expect(res.status).to.equal(200) + expect(await res.json()).to.deep.equal({ success: true }) + }) + + it('should match the headers with a matching function', async () => { + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool + .intercept({ + path: '/test', + method: 'GET', + headers (headers) { + expect(headers).to.be.an('object') + expect(headers).to.have.property('user-agent', 'undici') + return true + } + }) + .reply(200, { success: true }) + .persist() + + const res = await fetch('http://localhost:3000/test', { + method: 'GET', + headers: new Headers({ 'User-Agent': 'undici' }) + }) + + expect(res.status).to.equal(200) + expect(await res.json()).to.deep.equal({ success: true }) + }) +}) diff --git a/test/node-fetch/request.js b/test/node-fetch/request.js new file mode 100644 index 0000000..2d29d51 --- /dev/null +++ b/test/node-fetch/request.js @@ -0,0 +1,281 @@ +const stream = require('stream') +const http = require('http') + +const chai = require('chai') +const { Blob } = require('buffer') + +const Request = require('../../lib/fetch/request.js').Request +const TestServer = require('./utils/server.js') + +const { expect } = chai + +describe('Request', () => { + const local = new TestServer() + let base + + before(async () => { + await local.start() + base = `http://${local.hostname}:${local.port}/` + }) + + after(async () => { + return local.stop() + }) + + it('should have attributes conforming to Web IDL', () => { + const request = new Request('http://github.com/') + const enumerableProperties = [] + for (const property in request) { + enumerableProperties.push(property) + } + + for (const toCheck of [ + 'body', + 'bodyUsed', + 'arrayBuffer', + 'blob', + 'json', + 'text', + 'method', + 'url', + 'headers', + 'redirect', + 'clone', + 'signal' + ]) { + expect(enumerableProperties).to.contain(toCheck) + } + + // for (const toCheck of [ + // 'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect', 'signal' + // ]) { + // expect(() => { + // request[toCheck] = 'abc' + // }).to.throw() + // } + }) + + // it('should support wrapping Request instance', () => { + // const url = `${base}hello` + + // const form = new FormData() + // form.append('a', '1') + // const { signal } = new AbortController() + + // const r1 = new Request(url, { + // method: 'POST', + // follow: 1, + // body: form, + // signal + // }) + // const r2 = new Request(r1, { + // follow: 2 + // }) + + // expect(r2.url).to.equal(url) + // expect(r2.method).to.equal('POST') + // expect(r2.signal).to.equal(signal) + // // Note that we didn't clone the body + // expect(r2.body).to.equal(form) + // expect(r1.follow).to.equal(1) + // expect(r2.follow).to.equal(2) + // expect(r1.counter).to.equal(0) + // expect(r2.counter).to.equal(0) + // }) + + xit('should override signal on derived Request instances', () => { + const parentAbortController = new AbortController() + const derivedAbortController = new AbortController() + const parentRequest = new Request(`${base}hello`, { + signal: parentAbortController.signal + }) + const derivedRequest = new Request(parentRequest, { + signal: derivedAbortController.signal + }) + expect(parentRequest.signal).to.equal(parentAbortController.signal) + expect(derivedRequest.signal).to.equal(derivedAbortController.signal) + }) + + xit('should allow removing signal on derived Request instances', () => { + const parentAbortController = new AbortController() + const parentRequest = new Request(`${base}hello`, { + signal: parentAbortController.signal + }) + const derivedRequest = new Request(parentRequest, { + signal: null + }) + expect(parentRequest.signal).to.equal(parentAbortController.signal) + expect(derivedRequest.signal).to.equal(null) + }) + + it('should throw error with GET/HEAD requests with body', () => { + expect(() => new Request(base, { body: '' })) + .to.throw(TypeError) + expect(() => new Request(base, { body: 'a' })) + .to.throw(TypeError) + expect(() => new Request(base, { body: '', method: 'HEAD' })) + .to.throw(TypeError) + expect(() => new Request(base, { body: 'a', method: 'HEAD' })) + .to.throw(TypeError) + expect(() => new Request(base, { body: 'a', method: 'get' })) + .to.throw(TypeError) + expect(() => new Request(base, { body: 'a', method: 'head' })) + .to.throw(TypeError) + }) + + it('should default to null as body', () => { + const request = new Request(base) + expect(request.body).to.equal(null) + return request.text().then(result => expect(result).to.equal('')) + }) + + it('should support parsing headers', () => { + const url = base + const request = new Request(url, { + headers: { + a: '1' + } + }) + expect(request.url).to.equal(url) + expect(request.headers.get('a')).to.equal('1') + }) + + it('should support arrayBuffer() method', () => { + const url = base + const request = new Request(url, { + method: 'POST', + body: 'a=1' + }) + expect(request.url).to.equal(url) + return request.arrayBuffer().then(result => { + expect(result).to.be.an.instanceOf(ArrayBuffer) + const string = String.fromCharCode.apply(null, new Uint8Array(result)) + expect(string).to.equal('a=1') + }) + }) + + it('should support text() method', () => { + const url = base + const request = new Request(url, { + method: 'POST', + body: 'a=1' + }) + expect(request.url).to.equal(url) + return request.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support json() method', () => { + const url = base + const request = new Request(url, { + method: 'POST', + body: '{"a":1}' + }) + expect(request.url).to.equal(url) + return request.json().then(result => { + expect(result.a).to.equal(1) + }) + }) + + it('should support blob() method', () => { + const url = base + const request = new Request(url, { + method: 'POST', + body: Buffer.from('a=1') + }) + expect(request.url).to.equal(url) + return request.blob().then(result => { + expect(result).to.be.an.instanceOf(Blob) + expect(result.size).to.equal(3) + expect(result.type).to.equal('') + }) + }) + + it('should support clone() method', () => { + const url = base + const body = stream.Readable.from('a=1') + const agent = new http.Agent() + const { signal } = new AbortController() + const request = new Request(url, { + body, + method: 'POST', + redirect: 'manual', + headers: { + b: '2' + }, + follow: 3, + compress: false, + agent, + signal, + duplex: 'half' + }) + const cl = request.clone() + expect(cl.url).to.equal(url) + expect(cl.method).to.equal('POST') + expect(cl.redirect).to.equal('manual') + expect(cl.headers.get('b')).to.equal('2') + expect(cl.method).to.equal('POST') + // Clone body shouldn't be the same body + expect(cl.body).to.not.equal(body) + return Promise.all([cl.text(), request.text()]).then(results => { + expect(results[0]).to.equal('a=1') + expect(results[1]).to.equal('a=1') + }) + }) + + it('should support ArrayBuffer as body', () => { + const encoder = new TextEncoder() + const body = encoder.encode('a=12345678901234').buffer + const request = new Request(base, { + method: 'POST', + body + }) + new Uint8Array(body)[0] = 0 + return request.text().then(result => { + expect(result).to.equal('a=12345678901234') + }) + }) + + it('should support Uint8Array as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new Uint8Array(fullbuffer, 2, 9) + const request = new Request(base, { + method: 'POST', + body + }) + body[0] = 0 + return request.text().then(result => { + expect(result).to.equal('123456789') + }) + }) + + it('should support BigUint64Array as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new BigUint64Array(fullbuffer, 8, 1) + const request = new Request(base, { + method: 'POST', + body + }) + body[0] = 0n + return request.text().then(result => { + expect(result).to.equal('78901234') + }) + }) + + it('should support DataView as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new Uint8Array(fullbuffer, 2, 9) + const request = new Request(base, { + method: 'POST', + body + }) + body[0] = 0 + return request.text().then(result => { + expect(result).to.equal('123456789') + }) + }) +}) diff --git a/test/node-fetch/response.js b/test/node-fetch/response.js new file mode 100644 index 0000000..4bb7c42 --- /dev/null +++ b/test/node-fetch/response.js @@ -0,0 +1,251 @@ +/* eslint no-unused-expressions: "off" */ + +const chai = require('chai') +const stream = require('stream') +const { Response } = require('../../lib/fetch/response.js') +const TestServer = require('./utils/server.js') +const { Blob } = require('buffer') +const { kState } = require('../../lib/fetch/symbols.js') + +const { expect } = chai + +describe('Response', () => { + const local = new TestServer() + let base + + before(async () => { + await local.start() + base = `http://${local.hostname}:${local.port}/` + }) + + after(async () => { + return local.stop() + }) + + it('should have attributes conforming to Web IDL', () => { + const res = new Response() + const enumerableProperties = [] + for (const property in res) { + enumerableProperties.push(property) + } + + for (const toCheck of [ + 'body', + 'bodyUsed', + 'arrayBuffer', + 'blob', + 'json', + 'text', + 'type', + 'url', + 'status', + 'ok', + 'redirected', + 'statusText', + 'headers', + 'clone' + ]) { + expect(enumerableProperties).to.contain(toCheck) + } + + // TODO + // for (const toCheck of [ + // 'body', + // 'bodyUsed', + // 'type', + // 'url', + // 'status', + // 'ok', + // 'redirected', + // 'statusText', + // 'headers' + // ]) { + // expect(() => { + // res[toCheck] = 'abc' + // }).to.throw() + // } + }) + + it('should support empty options', () => { + const res = new Response(stream.Readable.from('a=1')) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support parsing headers', () => { + const res = new Response(null, { + headers: { + a: '1' + } + }) + expect(res.headers.get('a')).to.equal('1') + }) + + it('should support text() method', () => { + const res = new Response('a=1') + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support json() method', () => { + const res = new Response('{"a":1}') + return res.json().then(result => { + expect(result.a).to.equal(1) + }) + }) + + if (Blob) { + it('should support blob() method', () => { + const res = new Response('a=1', { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + } + }) + return res.blob().then(result => { + expect(result).to.be.an.instanceOf(Blob) + expect(result.size).to.equal(3) + expect(result.type).to.equal('text/plain') + }) + }) + } + + it('should support clone() method', () => { + const body = stream.Readable.from('a=1') + const res = new Response(body, { + headers: { + a: '1' + }, + status: 346, + statusText: 'production' + }) + res[kState].urlList = [new URL(base)] + const cl = res.clone() + expect(cl.headers.get('a')).to.equal('1') + expect(cl.type).to.equal('default') + expect(cl.url).to.equal(base) + expect(cl.status).to.equal(346) + expect(cl.statusText).to.equal('production') + expect(cl.ok).to.be.false + // Clone body shouldn't be the same body + expect(cl.body).to.not.equal(body) + return Promise.all([cl.text(), res.text()]).then(results => { + expect(results[0]).to.equal('a=1') + expect(results[1]).to.equal('a=1') + }) + }) + + it('should support stream as body', () => { + const body = stream.Readable.from('a=1') + const res = new Response(body) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support string as body', () => { + const res = new Response('a=1') + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support buffer as body', () => { + const res = new Response(Buffer.from('a=1')) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support ArrayBuffer as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const res = new Response(fullbuffer) + new Uint8Array(fullbuffer)[0] = 0 + return res.text().then(result => { + expect(result).to.equal('a=12345678901234') + }) + }) + + it('should support blob as body', async () => { + const res = new Response(new Blob(['a=1'])) + return res.text().then(result => { + expect(result).to.equal('a=1') + }) + }) + + it('should support Uint8Array as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new Uint8Array(fullbuffer, 2, 9) + const res = new Response(body) + body[0] = 0 + return res.text().then(result => { + expect(result).to.equal('123456789') + }) + }) + + it('should support BigUint64Array as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new BigUint64Array(fullbuffer, 8, 1) + const res = new Response(body) + body[0] = 0n + return res.text().then(result => { + expect(result).to.equal('78901234') + }) + }) + + it('should support DataView as body', () => { + const encoder = new TextEncoder() + const fullbuffer = encoder.encode('a=12345678901234').buffer + const body = new Uint8Array(fullbuffer, 2, 9) + const res = new Response(body) + body[0] = 0 + return res.text().then(result => { + expect(result).to.equal('123456789') + }) + }) + + it('should default to null as body', () => { + const res = new Response() + expect(res.body).to.equal(null) + + return res.text().then(result => expect(result).to.equal('')) + }) + + it('should default to 200 as status code', () => { + const res = new Response(null) + expect(res.status).to.equal(200) + }) + + it('should default to empty string as url', () => { + const res = new Response() + expect(res.url).to.equal('') + }) + + it('should support error() static method', () => { + const res = Response.error() + expect(res).to.be.an.instanceof(Response) + expect(res.type).to.equal('error') + expect(res.status).to.equal(0) + expect(res.statusText).to.equal('') + }) + + it('should support undefined status', () => { + const res = new Response(null, { status: undefined }) + expect(res.status).to.equal(200) + }) + + it('should support undefined statusText', () => { + const res = new Response(null, { statusText: undefined }) + expect(res.statusText).to.equal('') + }) + + it('should not set bodyUsed to undefined', () => { + const res = new Response() + expect(res.bodyUsed).to.be.false + }) +}) diff --git a/test/node-fetch/utils/chai-timeout.js b/test/node-fetch/utils/chai-timeout.js new file mode 100644 index 0000000..6838a4c --- /dev/null +++ b/test/node-fetch/utils/chai-timeout.js @@ -0,0 +1,15 @@ +const pTimeout = require('p-timeout') + +module.exports = ({ Assertion }, utils) => { + utils.addProperty(Assertion.prototype, 'timeout', async function () { + let timeouted = false + await pTimeout(this._obj, 150, () => { + timeouted = true + }) + return this.assert( + timeouted, + 'expected promise to timeout but it was resolved', + 'expected promise not to timeout but it timed out' + ) + }) +} diff --git a/test/node-fetch/utils/dummy.txt b/test/node-fetch/utils/dummy.txt new file mode 100644 index 0000000..5ca5191 --- /dev/null +++ b/test/node-fetch/utils/dummy.txt @@ -0,0 +1 @@ +i am a dummy \ No newline at end of file diff --git a/test/node-fetch/utils/read-stream.js b/test/node-fetch/utils/read-stream.js new file mode 100644 index 0000000..7d79153 --- /dev/null +++ b/test/node-fetch/utils/read-stream.js @@ -0,0 +1,9 @@ +module.exports = async function readStream (stream) { + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} diff --git a/test/node-fetch/utils/server.js b/test/node-fetch/utils/server.js new file mode 100644 index 0000000..46dc983 --- /dev/null +++ b/test/node-fetch/utils/server.js @@ -0,0 +1,467 @@ +const http = require('http') +const zlib = require('zlib') +const { once } = require('events') +const Busboy = require('@fastify/busboy') + +module.exports = class TestServer { + constructor () { + this.server = http.createServer(this.router) + // Node 8 default keepalive timeout is 5000ms + // make it shorter here as we want to close server quickly at the end of tests + this.server.keepAliveTimeout = 1000 + this.server.on('error', err => { + console.log(err.stack) + }) + this.server.on('connection', socket => { + socket.setTimeout(1500) + }) + } + + async start () { + this.server.listen(0, 'localhost') + return once(this.server, 'listening') + } + + async stop () { + this.server.close() + return once(this.server, 'close') + } + + get port () { + return this.server.address().port + } + + get hostname () { + return 'localhost' + } + + mockState (responseHandler) { + this.server.nextResponseHandler = responseHandler + return `http://${this.hostname}:${this.port}/mocked` + } + + router (request, res) { + const p = request.url + + if (p === '/mocked') { + if (this.nextResponseHandler) { + this.nextResponseHandler(res) + this.nextResponseHandler = undefined + } else { + throw new Error('No mocked response. Use ’TestServer.mockState()’.') + } + } + + if (p === '/hello') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('world') + } + + if (p.includes('question')) { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('ok') + } + + if (p === '/plain') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('text') + } + + if (p === '/no-status-text') { + res.writeHead(200, '', {}).end() + } + + if (p === '/options') { + res.statusCode = 200 + res.setHeader('Allow', 'GET, HEAD, OPTIONS') + res.end('hello world') + } + + if (p === '/html') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.end('') + } + + if (p === '/json') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + name: 'value' + })) + } + + if (p === '/gzip') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/gzip-truncated') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + // Truncate the CRC checksum and size check at the end of the stream + res.end(buffer.slice(0, -8)) + }) + } + + if (p === '/gzip-capital') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'GZip') + zlib.gzip('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/deflate') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'deflate') + zlib.deflate('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/brotli') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + if (typeof zlib.createBrotliDecompress === 'function') { + res.setHeader('Content-Encoding', 'br') + zlib.brotliCompress('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + } + + if (p === '/multiunsupported') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + if (typeof zlib.createBrotliDecompress === 'function') { + res.setHeader('Content-Encoding', 'br,asd,br') + res.end('multiunsupported') + } + } + + if (p === '/multisupported') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + if (typeof zlib.createBrotliDecompress === 'function') { + res.setHeader('Content-Encoding', 'br,br') + zlib.brotliCompress('hello world', (err, buffer) => { + if (err) { + throw err + } + + zlib.brotliCompress(buffer, (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + }) + } + } + + if (p === '/deflate-raw') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'deflate') + zlib.deflateRaw('hello world', (err, buffer) => { + if (err) { + throw err + } + + res.end(buffer) + }) + } + + if (p === '/sdch') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'sdch') + res.end('fake sdch string') + } + + if (p === '/invalid-content-encoding') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Encoding', 'gzip') + res.end('fake gzip string') + } + + if (p === '/timeout') { + setTimeout(() => { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('text') + }, 1000) + } + + if (p === '/slow') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.write('test') + setTimeout(() => { + res.end('test') + }, 1000) + } + + if (p === '/cookie') { + res.statusCode = 200 + res.setHeader('Set-Cookie', ['a=1', 'b=1']) + res.end('cookie') + } + + if (p === '/size/chunk') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + setTimeout(() => { + res.write('test') + }, 10) + setTimeout(() => { + res.end('test') + }, 20) + } + + if (p === '/size/long') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('testtest') + } + + if (p === '/redirect/301') { + res.statusCode = 301 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/302') { + res.statusCode = 302 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/303') { + res.statusCode = 303 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/307') { + res.statusCode = 307 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/308') { + res.statusCode = 308 + res.setHeader('Location', '/inspect') + res.end() + } + + if (p === '/redirect/chain') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/301') + res.end() + } + + if (p.startsWith('/redirect/chain/')) { + const count = parseInt(p.split('/').pop()) - 1 + res.statusCode = 301 + res.setHeader('Location', count ? `/redirect/chain/${count}` : '/redirect/301') + res.end() + } + + if (p === '/redirect/no-location') { + res.statusCode = 301 + res.end() + } + + if (p === '/redirect/slow') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/301') + setTimeout(() => { + res.end() + }, 1000) + } + + if (p === '/redirect/slow-chain') { + res.statusCode = 301 + res.setHeader('Location', '/redirect/slow') + setTimeout(() => { + res.end() + }, 10) + } + + if (p === '/redirect/slow-stream') { + res.statusCode = 301 + res.setHeader('Location', '/slow') + res.end() + } + + if (p === '/redirect/bad-location') { + res.socket.write('HTTP/1.1 301\r\nLocation: ☃\r\nContent-Length: 0\r\n') + res.socket.end('\r\n') + } + + if (p === '/error/400') { + res.statusCode = 400 + res.setHeader('Content-Type', 'text/plain') + res.end('client error') + } + + if (p === '/error/404') { + res.statusCode = 404 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/error/500') { + res.statusCode = 500 + res.setHeader('Content-Type', 'text/plain') + res.end('server error') + } + + if (p === '/error/reset') { + res.destroy() + } + + if (p === '/error/premature') { + res.writeHead(200, { 'content-length': 50 }) + res.write('foo') + setTimeout(() => { + res.destroy() + }, 100) + } + + if (p === '/error/premature/chunked') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked' + }) + + res.write(`${JSON.stringify({ data: 'hi' })}\n`) + + setTimeout(() => { + res.write(`${JSON.stringify({ data: 'bye' })}\n`) + }, 200) + + setTimeout(() => { + res.destroy() + }, 400) + } + + if (p === '/error/json') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end('invalid json') + } + + if (p === '/no-content') { + res.statusCode = 204 + res.end() + } + + if (p === '/no-content/gzip') { + res.statusCode = 204 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/no-content/brotli') { + res.statusCode = 204 + res.setHeader('Content-Encoding', 'br') + res.end() + } + + if (p === '/not-modified') { + res.statusCode = 304 + res.end() + } + + if (p === '/not-modified/gzip') { + res.statusCode = 304 + res.setHeader('Content-Encoding', 'gzip') + res.end() + } + + if (p === '/inspect') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + let body = '' + request.on('data', c => { + body += c + }) + request.on('end', () => { + res.end(JSON.stringify({ + method: request.method, + url: request.url, + headers: request.headers, + body + })) + }) + } + + if (p === '/multipart') { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + const busboy = new Busboy({ headers: request.headers }) + let body = '' + busboy.on('file', async (fieldName, file, fileName) => { + body += `${fieldName}=${fileName}` + // consume file data + // eslint-disable-next-line no-empty, no-unused-vars + for await (const c of file) {} + }) + + busboy.on('field', (fieldName, value) => { + body += `${fieldName}=${value}` + }) + busboy.on('finish', () => { + res.end(JSON.stringify({ + method: request.method, + url: request.url, + headers: request.headers, + body + })) + }) + request.pipe(busboy) + } + + if (p === '/m%C3%B6bius') { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('ok') + } + } +} diff --git a/test/parser-issues.js b/test/parser-issues.js new file mode 100644 index 0000000..b98edf1 --- /dev/null +++ b/test/parser-issues.js @@ -0,0 +1,114 @@ +const net = require('net') +const { test } = require('tap') +const { Client, errors } = require('..') + +test('https://github.com/mcollina/undici/issues/268', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Transfer-Encoding: chunked\r\n\r\n') + setTimeout(() => { + socket.write('1\r\n') + socket.write('\n\r\n') + setTimeout(() => { + socket.write('1\r\n') + socket.write('\n\r\n') + }, 500) + }, 500) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'GET', + path: '/nxt/_changes?feed=continuous&heartbeat=5000', + headersTimeout: 1e3 + }, (err, data) => { + t.error(err) + data.body + .resume() + setTimeout(() => { + t.pass() + data.body.on('error', () => {}) + }, 2e3) + }) + }) +}) + +test('parser fail', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTT/1.1 200 OK\r\n') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'GET', + path: '/' + }, (err, data) => { + t.ok(err) + t.type(err, errors.HTTPParserError) + }) + }) +}) + +test('split header field', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\nA') + setTimeout(() => { + socket.write('SD: asd,asd\r\n\r\n\r\n') + }, 100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'GET', + path: '/' + }, (err, data) => { + t.error(err) + t.equal(data.headers.asd, 'asd,asd') + data.body.destroy().on('error', () => {}) + }) + }) +}) + +test('split header value', (t) => { + t.plan(2) + + const server = net.createServer(socket => { + socket.write('HTTP/1.1 200 OK\r\nASD: asd') + setTimeout(() => { + socket.write(',asd\r\n\r\n\r\n') + }, 100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + method: 'GET', + path: '/' + }, (err, data) => { + t.error(err) + t.equal(data.headers.asd, 'asd,asd') + data.body.destroy().on('error', () => {}) + }) + }) +}) diff --git a/test/pipeline-pipelining.js b/test/pipeline-pipelining.js new file mode 100644 index 0000000..52a29d7 --- /dev/null +++ b/test/pipeline-pipelining.js @@ -0,0 +1,108 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { kConnect } = require('../lib/core/symbols') +const { kBusy, kPending, kRunning } = require('../lib/core/symbols') + +test('pipeline pipelining', (t) => { + t.plan(10) + + const server = createServer((req, res) => { + t.strictSame(req.headers['transfer-encoding'], undefined) + res.end() + }) + + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + t.teardown(client.close.bind(client)) + + client[kConnect](() => { + t.equal(client[kRunning], 0) + client.pipeline({ + method: 'GET', + path: '/' + }, ({ body }) => body).end().resume() + t.equal(client[kBusy], true) + t.strictSame(client[kRunning], 0) + t.strictSame(client[kPending], 1) + + client.pipeline({ + method: 'GET', + path: '/' + }, ({ body }) => body).end().resume() + t.equal(client[kBusy], true) + t.strictSame(client[kRunning], 0) + t.strictSame(client[kPending], 2) + process.nextTick(() => { + t.equal(client[kRunning], 2) + }) + }) + }) +}) + +test('pipeline pipelining retry', (t) => { + t.plan(13) + + let count = 0 + const server = createServer((req, res) => { + if (count++ === 0) { + res.destroy() + } else { + res.end() + } + }) + + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + t.teardown(client.destroy.bind(client)) + + client.once('disconnect', () => { + t.pass() + }) + + client[kConnect](() => { + client.pipeline({ + method: 'GET', + path: '/' + }, ({ body }) => body).end().resume() + .on('error', (err) => { + t.ok(err) + }) + t.equal(client[kBusy], true) + t.strictSame(client[kRunning], 0) + t.strictSame(client[kPending], 1) + + client.pipeline({ + method: 'GET', + path: '/' + }, ({ body }) => body).end().resume() + t.equal(client[kBusy], true) + t.strictSame(client[kRunning], 0) + t.strictSame(client[kPending], 2) + + client.pipeline({ + method: 'GET', + path: '/' + }, ({ body }) => body).end().resume() + t.equal(client[kBusy], true) + t.strictSame(client[kRunning], 0) + t.strictSame(client[kPending], 3) + + process.nextTick(() => { + t.equal(client[kRunning], 3) + }) + + client.close(() => { + t.pass() + }) + }) + }) +}) diff --git a/test/pool.js b/test/pool.js new file mode 100644 index 0000000..8cf7195 --- /dev/null +++ b/test/pool.js @@ -0,0 +1,1101 @@ +'use strict' + +const { EventEmitter } = require('events') +const { createServer } = require('http') +const net = require('net') +const { + finished, + PassThrough, + Readable +} = require('stream') +const { promisify } = require('util') +const proxyquire = require('proxyquire') +const { test } = require('tap') +const { + kBusy, + kPending, + kRunning, + kSize, + kUrl +} = require('../lib/core/symbols') +const { + Client, + Pool, + errors +} = require('..') + +test('throws when connection is inifinite', (t) => { + t.plan(2) + + try { + new Pool(null, { connections: 0 / 0 }) // eslint-disable-line + } catch (e) { + t.type(e, errors.InvalidArgumentError) + t.equal(e.message, 'invalid connections') + } +}) + +test('throws when connections is negative', (t) => { + t.plan(2) + + try { + new Pool(null, { connections: -1 }) // eslint-disable-line no-new + } catch (e) { + t.type(e, errors.InvalidArgumentError) + t.equal(e.message, 'invalid connections') + } +}) + +test('throws when connection is not number', (t) => { + t.plan(2) + + try { + new Pool(null, { connections: true }) // eslint-disable-line no-new + } catch (e) { + t.type(e, errors.InvalidArgumentError) + t.equal(e.message, 'invalid connections') + } +}) + +test('throws when factory is not a function', (t) => { + t.plan(2) + + try { + new Pool(null, { factory: '' }) // eslint-disable-line no-new + } catch (e) { + t.type(e, errors.InvalidArgumentError) + t.equal(e.message, 'factory must be a function.') + } +}) + +test('does not throw when connect is a function', (t) => { + t.plan(1) + + t.doesNotThrow(() => new Pool('http://localhost', { connect: () => {} })) +}) + +test('connect/disconnect event(s)', (t) => { + const clients = 2 + + t.plan(clients * 6) + + const server = createServer((req, res) => { + res.writeHead(200, { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=1s' + }) + res.end('ok') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const pool = new Pool(`http://localhost:${server.address().port}`, { + connections: clients, + keepAliveTimeoutThreshold: 100 + }) + t.teardown(pool.close.bind(pool)) + + pool.on('connect', (origin, [pool, client]) => { + t.equal(client instanceof Client, true) + }) + pool.on('disconnect', (origin, [pool, client], error) => { + t.ok(client instanceof Client) + t.type(error, errors.InformationalError) + t.equal(error.code, 'UND_ERR_INFO') + t.equal(error.message, 'socket idle timeout') + }) + + for (let i = 0; i < clients; i++) { + pool.request({ + path: '/', + method: 'GET' + }, (err, { headers, body }) => { + t.error(err) + body.resume() + }) + } + }) +}) + +test('basic get', (t) => { + t.plan(14) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + t.equal(client[kUrl].origin, `http://localhost:${server.address().port}`) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + t.equal(client.destroyed, false) + t.equal(client.closed, false) + client.close((err) => { + t.error(err) + t.equal(client.destroyed, true) + client.destroy((err) => { + t.error(err) + client.close((err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + t.equal(client.closed, true) + }) +}) + +test('URL as arg', (t) => { + t.plan(9) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const url = new URL('http://localhost') + url.port = server.address().port + const client = new Pool(url) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + client.close((err) => { + t.error(err) + client.destroy((err) => { + t.error(err) + client.close((err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +}) + +test('basic get error async/await', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await client.request({ path: '/', method: 'GET' }) + .catch((err) => { + t.ok(err) + }) + + await client.destroy() + + await client.close().catch((err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) +}) + +test('basic get with async/await', async (t) => { + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + + body.resume() + await promisify(finished)(body) + + await client.close() + await client.destroy() +}) + +test('stream get async/await', async (t) => { + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + await promisify(server.listen.bind(server))(0) + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await client.stream({ path: '/', method: 'GET' }, ({ statusCode, headers }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return new PassThrough() + }) +}) + +test('stream get error async/await', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + await client.stream({ path: '/', method: 'GET' }, () => { + + }) + .catch((err) => { + t.ok(err) + }) + }) +}) + +test('pipeline get', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const bufs = [] + client.pipeline({ path: '/', method: 'GET' }, ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + }) + .end() + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) +}) + +test('backpressure algorithm', (t) => { + const seen = [] + let total = 0 + + let writeMore = true + + class FakeClient extends EventEmitter { + constructor () { + super() + + this.id = total++ + } + + dispatch (req, handler) { + seen.push({ req, client: this, id: this.id }) + return writeMore + } + } + + const Pool = proxyquire('../lib/pool', { + './client': FakeClient + }) + + const noopHandler = { + onError (err) { + throw err + } + } + + const pool = new Pool('http://notahost') + + pool.dispatch({}, noopHandler) + pool.dispatch({}, noopHandler) + + const d1 = seen.shift() // d1 = c0 + t.equal(d1.id, 0) + const d2 = seen.shift() // d2 = c0 + t.equal(d2.id, 0) + + t.equal(d1.id, d2.id) + + writeMore = false + + pool.dispatch({}, noopHandler) // d3 = c0 + + pool.dispatch({}, noopHandler) // d4 = c1 + + const d3 = seen.shift() + t.equal(d3.id, 0) + const d4 = seen.shift() + t.equal(d4.id, 1) + + t.equal(d3.id, d2.id) + t.not(d3.id, d4.id) + + writeMore = true + + d4.client.emit('drain', new URL('http://notahost'), []) + + pool.dispatch({}, noopHandler) // d5 = c1 + + d3.client.emit('drain', new URL('http://notahost'), []) + + pool.dispatch({}, noopHandler) // d6 = c0 + + const d5 = seen.shift() + t.equal(d5.id, 1) + const d6 = seen.shift() + t.equal(d6.id, 0) + + t.equal(d5.id, d4.id) + t.equal(d3.id, d6.id) + + t.equal(total, 3) + + t.end() +}) + +test('busy', (t) => { + t.plan(8 * 16 + 2 + 1) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + const connections = 2 + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections, + pipelining: 2 + }) + client.on('drain', () => { + t.pass() + }) + client.on('connect', () => { + t.pass() + }) + t.teardown(client.destroy.bind(client)) + + for (let n = 1; n <= 8; ++n) { + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.equal(client[kPending], n) + t.equal(client[kBusy], n > 1) + t.equal(client[kSize], n) + t.equal(client[kRunning], 0) + + t.equal(client.stats.connected, 0) + t.equal(client.stats.free, 0) + t.equal(client.stats.queued, Math.max(n - connections, 0)) + t.equal(client.stats.pending, n) + t.equal(client.stats.size, n) + t.equal(client.stats.running, 0) + } + }) +}) + +test('invalid pool dispatch options', (t) => { + t.plan(2) + const pool = new Pool('http://notahost') + t.throws(() => pool.dispatch({}), errors.InvalidArgumentError, 'throws on invalid handler') + t.throws(() => pool.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler') +}) + +test('pool upgrade promise', (t) => { + t.plan(2) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + + c.on('end', () => { + c.end() + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { headers, socket } = await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.equal(recvData.toString(), 'Body') + }) + + t.same(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) +}) + +test('pool connect', (t) => { + t.plan(1) + + const server = createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = firstBodyChunk.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + const { socket } = await client.connect({ + path: '/' + }) + + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.equal(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }) +}) + +test('pool dispatch', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + let buf = '' + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + }, + onData (chunk) { + buf += chunk + }, + onComplete () { + t.equal(buf, 'asd') + }, + onError () { + } + }) + }) +}) + +test('pool pipeline args validation', (t) => { + t.plan(2) + + const client = new Pool('http://localhost:5000') + + const ret = client.pipeline(null, () => {}) + ret.on('error', (err) => { + t.ok(/opts/.test(err.message)) + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('300 requests succeed', (t) => { + t.plan(300 * 3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1 + }) + t.teardown(client.destroy.bind(client)) + + for (let n = 0; n < 300; ++n) { + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.error(err) + data.body.on('data', (chunk) => { + t.equal(chunk.toString(), 'asd') + }).on('end', () => { + t.pass() + }) + }) + } + }) +}) + +test('pool connect error', (t) => { + t.plan(1) + + const server = createServer((c) => { + t.fail() + }) + server.on('connect', (req, socket, firstBodyChunk) => { + socket.destroy() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + await client.connect({ + path: '/' + }) + } catch (err) { + t.ok(err) + } + }) +}) + +test('pool upgrade error', (t) => { + t.plan(1) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('\r\n') + c.write('Body') + }) + c.on('error', () => { + // Whether we get an error, end or close is undefined. + // Ignore error. + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + } catch (err) { + t.ok(err) + } + }) +}) + +test('pool dispatch error', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + }, + onData (chunk) { + }, + onComplete () { + t.pass() + }, + onError () { + } + }) + + client.dispatch({ + path: '/', + method: 'GET', + headers: { + 'transfer-encoding': 'fail' + } + }, { + onConnect () { + t.fail() + }, + onHeaders (statusCode, headers) { + t.fail() + }, + onData (chunk) { + t.fail() + }, + onError (err) { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + } + }) + }) +}) + +test('pool request abort in queue', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + }, + onData (chunk) { + }, + onComplete () { + t.pass() + }, + onError () { + } + }) + + const signal = new EventEmitter() + client.request({ + path: '/', + method: 'GET', + signal + }, (err) => { + t.equal(err.code, 'UND_ERR_ABORTED') + }) + signal.emit('abort') + }) +}) + +test('pool stream abort in queue', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + }, + onData (chunk) { + }, + onComplete () { + t.pass() + }, + onError () { + } + }) + + const signal = new EventEmitter() + client.stream({ + path: '/', + method: 'GET', + signal + }, ({ body }) => body, (err) => { + t.equal(err.code, 'UND_ERR_ABORTED') + }) + signal.emit('abort') + }) +}) + +test('pool pipeline abort in queue', (t) => { + t.plan(3) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers) { + t.equal(statusCode, 200) + }, + onData (chunk) { + }, + onComplete () { + t.pass() + }, + onError () { + } + }) + + const signal = new EventEmitter() + client.pipeline({ + path: '/', + method: 'GET', + signal + }, ({ body }) => body).end().on('error', (err) => { + t.equal(err.code, 'UND_ERR_ABORTED') + }) + signal.emit('abort') + }) +}) + +test('pool stream constructor error destroy body', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + { + const body = new Readable({ + read () { + } + }) + client.stream({ + path: '/', + method: 'GET', + body, + headers: { + 'transfer-encoding': 'fail' + } + }, () => { + t.fail() + }, (err) => { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(body.destroyed, true) + }) + } + + { + const body = new Readable({ + read () { + } + }) + client.stream({ + path: '/', + method: 'CONNECT', + body + }, () => { + t.fail() + }, (err) => { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(body.destroyed, true) + }) + } + }) +}) + +test('pool request constructor error destroy body', (t) => { + t.plan(4) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.close.bind(client)) + + { + const body = new Readable({ + read () { + } + }) + client.request({ + path: '/', + method: 'GET', + body, + headers: { + 'transfer-encoding': 'fail' + } + }, (err) => { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(body.destroyed, true) + }) + } + + { + const body = new Readable({ + read () { + } + }) + client.request({ + path: '/', + method: 'CONNECT', + body + }, (err) => { + t.equal(err.code, 'UND_ERR_INVALID_ARG') + t.equal(body.destroyed, true) + }) + } + }) +}) + +test('pool close waits for all requests', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.error(err) + }) + + client.close(() => { + t.pass() + }) + + client.close(() => { + t.pass() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.type(err, errors.ClientClosedError) + }) + }) +}) + +test('pool destroyed', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + client.destroy() + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) +}) + +test('pool destroy fails queued requests', (t) => { + t.plan(6) + + const server = createServer((req, res) => { + res.end('asd') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`, { + connections: 1, + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + const _err = new Error() + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err, _err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.equal(err, _err) + }) + + t.equal(client.destroyed, false) + client.destroy(_err, () => { + t.pass() + }) + t.equal(client.destroyed, true) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.type(err, errors.ClientDestroyedError) + }) + }) +}) diff --git a/test/promises.js b/test/promises.js new file mode 100644 index 0000000..524fdfc --- /dev/null +++ b/test/promises.js @@ -0,0 +1,280 @@ +'use strict' + +const { test } = require('tap') +const { Client, Pool } = require('..') +const { createServer } = require('http') +const { readFileSync, createReadStream } = require('fs') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') + +test('basic get, async await support', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) + +function postServer (t, expected) { + return function (req, res) { + t.equal(req.url, '/') + t.equal(req.method, 'POST') + + req.setEncoding('utf8') + let data = '' + + req.on('data', function (d) { data += d }) + + req.on('end', () => { + t.equal(data, expected) + res.end('hello') + }) + } +} + +test('basic POST with string, async await support', (t) => { + t.plan(5) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, body } = await client.request({ path: '/', method: 'POST', body: expected }) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) + +test('basic POST with Buffer, async await support', (t) => { + t.plan(5) + + const expected = readFileSync(__filename) + + const server = createServer(postServer(t, expected.toString())) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, body } = await client.request({ path: '/', method: 'POST', body: expected }) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) + +test('basic POST with stream, async await support', (t) => { + t.plan(5) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, body } = await client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + body: createReadStream(__filename) + }) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) + +test('basic POST with async-iterator, async await support', (t) => { + t.plan(5) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, body } = await client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + body: wrapWithAsyncIterable(createReadStream(__filename)) + }) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) + +test('20 times GET with pipelining 10, async await support', (t) => { + const num = 20 + t.plan(2 * num + 1) + + const sleep = ms => new Promise((resolve, reject) => { + setTimeout(resolve, ms) + }) + + let count = 0 + let countGreaterThanOne = false + const server = createServer(async (req, res) => { + count++ + await sleep(10) + countGreaterThanOne = countGreaterThanOne || count > 1 + res.end(req.url) + }) + t.teardown(server.close.bind(server)) + + // needed to check for a warning on the maxListeners on the socket + function onWarning (warning) { + if (!/ExperimentalWarning/.test(warning)) { + t.fail() + } + } + process.on('warning', onWarning) + t.teardown(() => { + process.removeListener('warning', onWarning) + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + t.teardown(client.close.bind(client)) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + async function makeRequest (i) { + await makeRequestAndExpectUrl(client, i, t) + count-- + if (i === num - 1) { + t.ok(countGreaterThanOne, 'seen more than one parallel request') + } + } + }) +}) + +async function makeRequestAndExpectUrl (client, i, t) { + try { + const { statusCode, body } = await client.request({ path: '/' + i, method: 'GET' }) + t.equal(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('/' + i, Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + return true +} + +test('pool, async await support', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + try { + const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' }) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + } catch (err) { + t.fail(err) + } + }) +}) diff --git a/test/proxy-agent.js b/test/proxy-agent.js new file mode 100644 index 0000000..0a92126 --- /dev/null +++ b/test/proxy-agent.js @@ -0,0 +1,720 @@ +'use strict' + +const { test, teardown } = require('tap') +const { request, fetch, setGlobalDispatcher, getGlobalDispatcher } = require('..') +const { InvalidArgumentError } = require('../lib/core/errors') +const { nodeMajor } = require('../lib/core/util') +const { readFileSync } = require('fs') +const { join } = require('path') +const ProxyAgent = require('../lib/proxy-agent') +const Pool = require('../lib/pool') +const { createServer } = require('http') +const https = require('https') +const proxy = require('proxy') + +test('should throw error when no uri is provided', (t) => { + t.plan(2) + t.throws(() => new ProxyAgent(), InvalidArgumentError) + t.throws(() => new ProxyAgent({}), InvalidArgumentError) +}) + +test('using auth in combination with token should throw', (t) => { + t.plan(1) + t.throws(() => new ProxyAgent({ + auth: 'foo', + token: 'Bearer bar', + uri: 'http://example.com' + }), + InvalidArgumentError + ) +}) + +test('should accept string and object as options', (t) => { + t.plan(2) + t.doesNotThrow(() => new ProxyAgent('http://example.com')) + t.doesNotThrow(() => new ProxyAgent({ uri: 'http://example.com' })) +}) + +test('use proxy-agent to connect through proxy', async (t) => { + t.plan(6) + const server = await buildServer() + const proxy = await buildProxy() + delete proxy.authenticate + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent(proxyUrl) + const parsedOrigin = new URL(serverUrl) + + proxy.on('connect', () => { + t.pass('should connect to proxy') + }) + + server.on('request', (req, res) => { + t.equal(req.url, '/') + t.equal(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await body.json() + + t.equal(statusCode, 200) + t.same(json, { hello: 'world' }) + t.equal(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy agent to connect through proxy using Pool', async (t) => { + t.plan(3) + const server = await buildServer() + const proxy = await buildProxy() + let resolveFirstConnect + let connectCount = 0 + + proxy.authenticate = async function (req, fn) { + if (++connectCount === 2) { + t.pass('second connect should arrive while first is still inflight') + resolveFirstConnect() + fn(null, true) + } else { + await new Promise((resolve) => { + resolveFirstConnect = resolve + }) + fn(null, true) + } + } + + server.on('request', (req, res) => { + res.end() + }) + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const clientFactory = (url, options) => { + return new Pool(url, options) + } + const proxyAgent = new ProxyAgent({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory }) + const firstRequest = request(`${serverUrl}`, { dispatcher: proxyAgent }) + const secondRequest = await request(`${serverUrl}`, { dispatcher: proxyAgent }) + t.equal((await firstRequest).statusCode, 200) + t.equal(secondRequest.statusCode, 200) + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent to connect through proxy using path with params', async (t) => { + t.plan(6) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent(proxyUrl) + const parsedOrigin = new URL(serverUrl) + + proxy.on('connect', () => { + t.pass('should call proxy') + }) + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + t.equal(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.equal(statusCode, 200) + t.same(json, { hello: 'world' }) + t.equal(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with auth', async (t) => { + t.plan(7) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + auth: Buffer.from('user:pass').toString('base64'), + uri: proxyUrl + }) + const parsedOrigin = new URL(serverUrl) + + proxy.authenticate = function (req, fn) { + t.pass('authentication should be called') + fn(null, req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`) + } + proxy.on('connect', () => { + t.pass('proxy should be called') + }) + + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + t.equal(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.equal(statusCode, 200) + t.same(json, { hello: 'world' }) + t.equal(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with token', async (t) => { + t.plan(7) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, + uri: proxyUrl + }) + const parsedOrigin = new URL(serverUrl) + + proxy.authenticate = function (req, fn) { + t.pass('authentication should be called') + fn(null, req.headers['proxy-authorization'] === `Bearer ${Buffer.from('user:pass').toString('base64')}`) + } + proxy.on('connect', () => { + t.pass('proxy should be called') + }) + + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + t.equal(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.equal(statusCode, 200) + t.same(json, { hello: 'world' }) + t.equal(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with custom headers', async (t) => { + t.plan(2) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + uri: proxyUrl, + headers: { + 'User-Agent': 'Foobar/1.0.0' + } + }) + + proxy.on('connect', (req) => { + t.equal(req.headers['user-agent'], 'Foobar/1.0.0') + }) + + server.on('request', (req, res) => { + t.equal(req.headers['user-agent'], 'BarBaz/1.0.0') + res.end() + }) + + await request(serverUrl + '/hello?foo=bar', { + headers: { 'user-agent': 'BarBaz/1.0.0' }, + dispatcher: proxyAgent + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('sending proxy-authorization in request headers should throw', async (t) => { + t.plan(3) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent(proxyUrl) + + server.on('request', (req, res) => { + res.end(JSON.stringify({ hello: 'world' })) + }) + + await t.rejects( + request( + serverUrl + '/hello?foo=bar', + { + dispatcher: proxyAgent, + headers: { + 'proxy-authorization': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + await t.rejects( + request( + serverUrl + '/hello?foo=bar', + { + dispatcher: proxyAgent, + headers: { + 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + await t.rejects( + request( + serverUrl + '/hello?foo=bar', + { + dispatcher: proxyAgent, + headers: { + 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with setGlobalDispatcher', async (t) => { + t.plan(6) + const defaultDispatcher = getGlobalDispatcher() + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent(proxyUrl) + const parsedOrigin = new URL(serverUrl) + setGlobalDispatcher(proxyAgent) + + t.teardown(() => setGlobalDispatcher(defaultDispatcher)) + + proxy.on('connect', () => { + t.pass('should call proxy') + }) + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + t.equal(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar') + const json = await body.json() + + t.equal(statusCode, 200) + t.same(json, { hello: 'world' }) + t.equal(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', { skip: nodeMajor < 16 }, async (t) => { + t.plan(2) + const defaultDispatcher = getGlobalDispatcher() + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + const proxyAgent = new ProxyAgent(proxyUrl) + setGlobalDispatcher(proxyAgent) + + t.teardown(() => setGlobalDispatcher(defaultDispatcher)) + + const expectedHeaders = { + host: `localhost:${server.address().port}`, + connection: 'keep-alive', + 'test-header': 'value', + accept: '*/*', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'undici', + 'accept-encoding': 'gzip, deflate' + } + + const expectedProxyHeaders = { + host: `localhost:${proxy.address().port}`, + connection: 'close' + } + + proxy.on('connect', (req, res) => { + t.same(req.headers, expectedProxyHeaders) + }) + + server.on('request', (req, res) => { + t.same(req.headers, expectedHeaders) + res.end('goodbye') + }) + + await fetch(serverUrl, { + headers: { 'Test-header': 'value' } + }) + + server.close() + proxy.close() + proxyAgent.close() + t.end() +}) + +test('should throw when proxy does not return 200', async (t) => { + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (req, fn) { + fn(null, false) + } + + const proxyAgent = new ProxyAgent(proxyUrl) + try { + await request(serverUrl, { dispatcher: proxyAgent }) + t.fail() + } catch (e) { + t.pass() + t.ok(e) + } + + server.close() + proxy.close() + proxyAgent.close() + t.end() +}) + +test('pass ProxyAgent proxy status code error when using fetch - #2161', { skip: nodeMajor < 16 }, async (t) => { + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (req, fn) { + fn(null, false) + } + + const proxyAgent = new ProxyAgent(proxyUrl) + try { + await fetch(serverUrl, { dispatcher: proxyAgent }) + } catch (e) { + t.hasProp(e, 'cause') + } + + server.close() + proxy.close() + proxyAgent.close() + t.end() +}) + +test('Proxy via HTTP to HTTPS endpoint', async (t) => { + t.plan(4) + + const server = await buildSSLServer() + const proxy = await buildProxy() + + const serverUrl = `https://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + uri: proxyUrl, + requestTls: { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1' + } + }) + + server.on('request', function (req, res) { + t.ok(req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.pass('server should be connected secured') + }) + + proxy.on('secureConnection', () => { + t.fail('proxy over http should not call secureConnection') + }) + + proxy.on('connect', function () { + t.pass('proxy should be connected') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.strictSame(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('Proxy via HTTPS to HTTPS endpoint', async (t) => { + t.plan(5) + const server = await buildSSLServer() + const proxy = await buildSSLProxy() + + const serverUrl = `https://localhost:${server.address().port}` + const proxyUrl = `https://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1', + rejectUnauthorized: false + }, + requestTls: { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1' + } + }) + + server.on('request', function (req, res) { + t.ok(req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.pass('server should be connected secured') + }) + + proxy.on('secureConnection', () => { + t.pass('proxy over http should call secureConnection') + }) + + proxy.on('connect', function () { + t.pass('proxy should be connected') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.strictSame(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('Proxy via HTTPS to HTTP endpoint', async (t) => { + t.plan(3) + const server = await buildServer() + const proxy = await buildSSLProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `https://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1', + rejectUnauthorized: false + } + }) + + server.on('request', function (req, res) { + t.ok(!req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.fail('server is http') + }) + + proxy.on('secureConnection', () => { + t.pass('proxy over http should call secureConnection') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.strictSame(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('Proxy via HTTP to HTTP endpoint', async (t) => { + t.plan(3) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent(proxyUrl) + + server.on('request', function (req, res) { + t.ok(!req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.fail('server is http') + }) + + proxy.on('secureConnection', () => { + t.fail('proxy is http') + }) + + proxy.on('connect', () => { + t.pass('connect to proxy') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.strictSame(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +function buildServer () { + return new Promise((resolve) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildSSLServer () { + const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + } + return new Promise((resolve) => { + const server = https.createServer(serverOptions) + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy (listener) { + return new Promise((resolve) => { + const server = listener + ? proxy(createServer(listener)) + : proxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} + +function buildSSLProxy () { + const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + } + + return new Promise((resolve) => { + const server = proxy(https.createServer(serverOptions)) + server.listen(0, () => resolve(server)) + }) +} + +teardown(() => process.exit()) diff --git a/test/proxy.js b/test/proxy.js new file mode 100644 index 0000000..d6d8d42 --- /dev/null +++ b/test/proxy.js @@ -0,0 +1,132 @@ +'use strict' + +const { test } = require('tap') +const { Client, Pool } = require('..') +const { createServer } = require('http') +const proxy = require('proxy') + +test('connect through proxy', async (t) => { + t.plan(3) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const client = new Client(proxyUrl) + + const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar' + }) + + response.body.setEncoding('utf8') + let data = '' + for await (const chunk of response.body) { + data += chunk + } + t.equal(response.statusCode, 200) + t.same(JSON.parse(data), { hello: 'world' }) + + server.close() + proxy.close() + client.close() +}) + +test('connect through proxy with auth', async (t) => { + t.plan(3) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (req, fn) { + fn(null, req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`) + } + + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const client = new Client(proxyUrl) + + const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar', + headers: { + 'proxy-authorization': `Basic ${Buffer.from('user:pass').toString('base64')}` + } + }) + + response.body.setEncoding('utf8') + let data = '' + for await (const chunk of response.body) { + data += chunk + } + t.equal(response.statusCode, 200) + t.same(JSON.parse(data), { hello: 'world' }) + + server.close() + proxy.close() + client.close() +}) + +test('connect through proxy (with pool)', async (t) => { + t.plan(3) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + server.on('request', (req, res) => { + t.equal(req.url, '/hello?foo=bar') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const pool = new Pool(proxyUrl) + + const response = await pool.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar' + }) + + response.body.setEncoding('utf8') + let data = '' + for await (const chunk of response.body) { + data += chunk + } + t.equal(response.statusCode, 200) + t.same(JSON.parse(data), { hello: 'world' }) + + server.close() + proxy.close() + pool.close() +}) + +function buildServer () { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy () { + return new Promise((resolve, reject) => { + const server = proxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} diff --git a/test/readable.test.js b/test/readable.test.js new file mode 100644 index 0000000..3f4f793 --- /dev/null +++ b/test/readable.test.js @@ -0,0 +1,23 @@ +'use strict' + +const { test } = require('tap') +const Readable = require('../lib/api/readable') + +test('avoid body reordering', async function (t) { + function resume () { + } + function abort () { + } + const r = new Readable({ resume, abort }) + + r.push(Buffer.from('hello')) + + process.nextTick(() => { + r.push(Buffer.from('world')) + r.push(null) + }) + + const text = await r.text() + + t.equal(text, 'helloworld') +}) diff --git a/test/redirect-cross-origin-header.js b/test/redirect-cross-origin-header.js new file mode 100644 index 0000000..8313e00 --- /dev/null +++ b/test/redirect-cross-origin-header.js @@ -0,0 +1,51 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { request } = require('..') + +test('Cross-origin redirects clear forbidden headers', async (t) => { + t.plan(6) + + const server1 = createServer((req, res) => { + t.equal(req.headers.cookie, undefined) + t.equal(req.headers.authorization, undefined) + t.equal(req.headers['proxy-authorization'], undefined) + + res.end('redirected') + }).listen(0) + + const server2 = createServer((req, res) => { + t.equal(req.headers.authorization, 'test') + t.equal(req.headers.cookie, 'ddd=dddd') + + res.writeHead(302, { + ...req.headers, + Location: `http://localhost:${server1.address().port}` + }) + res.end() + }).listen(0) + + t.teardown(() => { + server1.close() + server2.close() + }) + + await Promise.all([ + once(server1, 'listening'), + once(server2, 'listening') + ]) + + const res = await request(`http://localhost:${server2.address().port}`, { + maxRedirections: 1, + headers: { + Authorization: 'test', + Cookie: 'ddd=dddd', + 'Proxy-Authorization': 'test' + } + }) + + const text = await res.body.text() + t.equal(text, 'redirected') +}) diff --git a/test/redirect-pipeline.js b/test/redirect-pipeline.js new file mode 100644 index 0000000..e4be837 --- /dev/null +++ b/test/redirect-pipeline.js @@ -0,0 +1,50 @@ +'use strict' + +const t = require('tap') +const { pipeline: undiciPipeline } = require('..') +const { pipeline: streamPipelineCb } = require('stream') +const { promisify } = require('util') +const { createReadable, createWritable } = require('./utils/stream') +const { startRedirectingServer } = require('./utils/redirecting-servers') + +const streamPipeline = promisify(streamPipelineCb) + +t.test('should not follow redirection by default if not using RedirectAgent', async t => { + t.plan(3) + + const body = [] + const serverRoot = await startRedirectingServer(t) + + await streamPipeline( + createReadable('REQUEST'), + undiciPipeline(`http://${serverRoot}/`, {}, ({ statusCode, headers, body }) => { + t.equal(statusCode, 302) + t.equal(headers.location, `http://${serverRoot}/302/1`) + + return body + }), + createWritable(body) + ) + + t.equal(body.length, 0) +}) + +t.test('should not follow redirects when using RedirectAgent within pipeline', async t => { + t.plan(3) + + const body = [] + const serverRoot = await startRedirectingServer(t) + + await streamPipeline( + createReadable('REQUEST'), + undiciPipeline(`http://${serverRoot}/`, { maxRedirections: 1 }, ({ statusCode, headers, body }) => { + t.equal(statusCode, 302) + t.equal(headers.location, `http://${serverRoot}/302/1`) + + return body + }), + createWritable(body) + ) + + t.equal(body.length, 0) +}) diff --git a/test/redirect-relative.js b/test/redirect-relative.js new file mode 100644 index 0000000..ca9c541 --- /dev/null +++ b/test/redirect-relative.js @@ -0,0 +1,22 @@ +'use strict' + +const t = require('tap') +const { request } = require('..') +const { + startRedirectingWithRelativePath +} = require('./utils/redirecting-servers') + +t.test('should redirect to relative URL according to RFC 7231', async t => { + t.plan(2) + + const server = await startRedirectingWithRelativePath(t) + + const { statusCode, body } = await request(`http://${server}`, { + maxRedirections: 3 + }) + + const finalPath = await body.text() + + t.equal(statusCode, 200) + t.equal(finalPath, '/absolute/b') +}) diff --git a/test/redirect-request.js b/test/redirect-request.js new file mode 100644 index 0000000..5a1ae6d --- /dev/null +++ b/test/redirect-request.js @@ -0,0 +1,420 @@ +'use strict' + +const t = require('tap') +const undici = require('..') +const { nodeMajor } = require('../lib/core/util') +const { + startRedirectingServer, + startRedirectingWithBodyServer, + startRedirectingChainServers, + startRedirectingWithoutLocationServer, + startRedirectingWithAuthorization, + startRedirectingWithCookie, + startRedirectingWithQueryParams +} = require('./utils/redirecting-servers') +const { createReadable, createReadableStream } = require('./utils/stream') + +for (const factory of [ + (server, opts) => new undici.Agent(opts), + (server, opts) => new undici.Pool(`http://${server}`, opts), + (server, opts) => new undici.Client(`http://${server}`, opts) +]) { + const request = (t, server, opts, ...args) => { + const dispatcher = factory(server, opts) + t.teardown(() => dispatcher.close()) + return undici.request(args[0], { ...args[1], dispatcher }, args[2]) + } + + t.test('should always have a history with the final URL even if no redirections were followed', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await request(t, server, undefined, `http://${server}/200?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [`http://${server}/200?key=value`]) + t.equal(body, `GET /5 key=value :: host@${server} connection@keep-alive`) + }) + + t.test('should not follow redirection by default if not using RedirectAgent', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}`) + const body = await bodyStream.text() + + t.equal(statusCode, 302) + t.equal(headers.location, `http://${server}/302/1`) + t.equal(body.length, 0) + }) + + t.test('should follow redirection after a HTTP 300', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await request(t, server, undefined, `http://${server}/300?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ]) + t.equal(body, `GET /5 key=value :: host@${server} connection@keep-alive`) + }) + + t.test('should follow redirection after a HTTP 300 default', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await request(t, server, { maxRedirections: 10 }, `http://${server}/300?key=value`) + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ]) + t.equal(body, `GET /5 key=value :: host@${server} connection@keep-alive`) + }) + + t.test('should follow redirection after a HTTP 301', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `POST /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST`) + }) + + t.test('should follow redirection after a HTTP 302', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/302`, { + method: 'PUT', + body: Buffer.from('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `PUT /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST`) + }) + + t.test('should follow redirection after a HTTP 303 changing method to GET', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `GET /5 :: host@${server} connection@keep-alive`) + }) + + t.test('should remove Host and request body related headers when following HTTP 303 (array)', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: [ + 'Content-Encoding', + 'gzip', + 'X-Foo1', + '1', + 'X-Foo2', + '2', + 'Content-Type', + 'application/json', + 'X-Foo3', + '3', + 'Host', + 'localhost', + 'X-Bar', + '4' + ], + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4`) + }) + + t.test('should remove Host and request body related headers when following HTTP 303 (object)', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: { + 'Content-Encoding': 'gzip', + 'X-Foo1': '1', + 'X-Foo2': '2', + 'Content-Type': 'application/json', + 'X-Foo3': '3', + Host: 'localhost', + 'X-Bar': '4' + }, + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4`) + }) + + t.test('should follow redirection after a HTTP 307', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/307`, { + method: 'DELETE', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `DELETE /5 :: host@${server} connection@keep-alive`) + }) + + t.test('should follow redirection after a HTTP 308', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/308`, { + method: 'OPTIONS', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.equal(body, `OPTIONS /5 :: host@${server} connection@keep-alive`) + }) + + t.test('should ignore HTTP 3xx response bodies', async t => { + const server = await startRedirectingWithBodyServer(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [`http://${server}/`, `http://${server}/end`]) + t.equal(body, 'FINAL') + }) + + t.test('should ignore query after redirection', async t => { + const server = await startRedirectingWithQueryParams(t) + + const { statusCode, headers, context: { history } } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10, + query: { param1: 'first' } + }) + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [`http://${server}/`, `http://${server}/?param2=second`]) + }) + + t.test('should follow a redirect chain up to the allowed number of times', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await request(t, server, undefined, `http://${server}/300`, { + maxRedirections: 2 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 300) + t.equal(headers.location, `http://${server}/300/3`) + t.same(history.map(x => x.toString()), [`http://${server}/300`, `http://${server}/300/1`, `http://${server}/300/2`]) + t.equal(body.length, 0) + }) + + t.test('when a Location response header is NOT present', async t => { + const redirectCodes = [300, 301, 302, 303, 307, 308] + const server = await startRedirectingWithoutLocationServer(t) + + for (const code of redirectCodes) { + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/${code}`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, code) + t.notOk(headers.location) + t.equal(body.length, 0) + } + }) + + t.test('should not allow invalid maxRedirections arguments', async t => { + try { + await request(t, 'localhost', undefined, 'http://localhost', { + method: 'GET', + maxRedirections: 'INVALID' + }) + + t.fail('Did not throw') + } catch (err) { + t.equal(err.message, 'maxRedirections must be a positive number') + } + }) + + t.test('should not allow invalid maxRedirections arguments default', async t => { + try { + await request(t, 'localhost', { + maxRedirections: 'INVALID' + }, 'http://localhost', { + method: 'GET' + }) + + t.fail('Did not throw') + } catch (err) { + t.equal(err.message, 'maxRedirections must be a positive number') + } + }) + + t.test('should not follow redirects when using ReadableStream request bodies', { skip: nodeMajor < 16 }, async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadableStream('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 301) + t.equal(headers.location, `http://${server}/301/2`) + t.equal(body.length, 0) + }) + + t.test('should not follow redirects when using Readable request bodies', async t => { + const server = await startRedirectingServer(t) + + const { statusCode, headers, body: bodyStream } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadable('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 301) + t.equal(headers.location, `http://${server}/301/1`) + t.equal(body.length, 0) + }) +} + +t.test('should follow redirections when going cross origin', async t => { + const [server1, server2, server3] = await startRedirectingChainServers(t) + + const { statusCode, headers, body: bodyStream, context: { history } } = await undici.request(`http://${server1}`, { + method: 'POST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server1}/`, + `http://${server2}/`, + `http://${server3}/`, + `http://${server2}/end`, + `http://${server3}/end`, + `http://${server1}/end` + ]) + t.equal(body, 'POST') +}) + +t.test('should handle errors (callback)', t => { + t.plan(1) + + undici.request( + 'http://localhost:0', + { + maxRedirections: 10 + }, + error => { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } + ) +}) + +t.test('should handle errors (promise)', async t => { + try { + await undici.request('http://localhost:0', { maxRedirections: 10 }) + t.fail('Did not throw') + } catch (error) { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } +}) + +t.test('removes authorization header on third party origin', async t => { + const [server1] = await startRedirectingWithAuthorization(t, 'secret') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + authorization: 'secret' + } + }) + + const body = await bodyStream.text() + + t.equal(body, '') +}) + +t.test('removes cookie header on third party origin', async t => { + const [server1] = await startRedirectingWithCookie(t, 'a=b') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + cookie: 'a=b' + } + }) + + const body = await bodyStream.text() + + t.equal(body, '') +}) diff --git a/test/redirect-stream.js b/test/redirect-stream.js new file mode 100644 index 0000000..55dd97b --- /dev/null +++ b/test/redirect-stream.js @@ -0,0 +1,423 @@ +'use strict' + +const t = require('tap') +const { stream } = require('..') +const { + startRedirectingServer, + startRedirectingWithBodyServer, + startRedirectingChainServers, + startRedirectingWithoutLocationServer, + startRedirectingWithAuthorization, + startRedirectingWithCookie +} = require('./utils/redirecting-servers') +const { createReadable, createWritable } = require('./utils/stream') + +t.test('should always have a history with the final URL even if no redirections were followed', async t => { + t.plan(4) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/200?key=value`, + { opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque, context: { history } }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server}/200?key=value` + ]) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `GET /5 key=value :: host@${server} connection@keep-alive`) +}) + +t.test('should not follow redirection by default if not using RedirectAgent', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream(`http://${server}`, { opaque: body }, ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 302) + t.equal(headers.location, `http://${server}/302/1`) + + return createWritable(opaque) + }) + + t.equal(body.length, 0) +}) + +t.test('should follow redirection after a HTTP 300', async t => { + t.plan(4) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/300?key=value`, + { opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque, context: { history } }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ]) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `GET /5 key=value :: host@${server} connection@keep-alive`) +}) + +t.test('should follow redirection after a HTTP 301', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/301`, + { method: 'POST', body: 'REQUEST', opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `POST /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST`) +}) + +t.test('should follow redirection after a HTTP 302', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/302`, + { method: 'PUT', body: Buffer.from('REQUEST'), opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `PUT /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST`) +}) + +t.test('should follow redirection after a HTTP 303 changing method to GET', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream(`http://${server}/303`, { opaque: body, maxRedirections: 10 }, ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + }) + + t.equal(body.join(''), `GET /5 :: host@${server} connection@keep-alive`) +}) + +t.test('should remove Host and request body related headers when following HTTP 303 (array)', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/303`, + { + method: 'PATCH', + headers: [ + 'Content-Encoding', + 'gzip', + 'X-Foo1', + '1', + 'X-Foo2', + '2', + 'Content-Type', + 'application/json', + 'X-Foo3', + '3', + 'Host', + 'localhost', + 'X-Bar', + '4' + ], + opaque: body, + maxRedirections: 10 + }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4`) +}) + +t.test('should remove Host and request body related headers when following HTTP 303 (object)', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/303`, + { + method: 'PATCH', + headers: { + 'Content-Encoding': 'gzip', + 'X-Foo1': '1', + 'X-Foo2': '2', + 'Content-Type': 'application/json', + 'X-Foo3': '3', + Host: 'localhost', + 'X-Bar': '4' + }, + opaque: body, + maxRedirections: 10 + }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4`) +}) + +t.test('should follow redirection after a HTTP 307', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/307`, + { method: 'DELETE', opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `DELETE /5 :: host@${server} connection@keep-alive`) +}) + +t.test('should follow redirection after a HTTP 308', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/308`, + { method: 'OPTIONS', opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), `OPTIONS /5 :: host@${server} connection@keep-alive`) +}) + +t.test('should ignore HTTP 3xx response bodies', async t => { + t.plan(4) + + const body = [] + const server = await startRedirectingWithBodyServer(t) + + await stream( + `http://${server}/`, + { opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque, context: { history } }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [`http://${server}/`, `http://${server}/end`]) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), 'FINAL') +}) + +t.test('should follow a redirect chain up to the allowed number of times', async t => { + t.plan(4) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}/300`, + { opaque: body, maxRedirections: 2 }, + ({ statusCode, headers, opaque, context: { history } }) => { + t.equal(statusCode, 300) + t.equal(headers.location, `http://${server}/300/3`) + t.same(history.map(x => x.toString()), [`http://${server}/300`, `http://${server}/300/1`, `http://${server}/300/2`]) + + return createWritable(opaque) + } + ) + + t.equal(body.length, 0) +}) + +t.test('should follow redirections when going cross origin', async t => { + t.plan(4) + + const [server1, server2, server3] = await startRedirectingChainServers(t) + const body = [] + + await stream( + `http://${server1}`, + { method: 'POST', opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque, context: { history } }) => { + t.equal(statusCode, 200) + t.notOk(headers.location) + t.same(history.map(x => x.toString()), [ + `http://${server1}/`, + `http://${server2}/`, + `http://${server3}/`, + `http://${server2}/end`, + `http://${server3}/end`, + `http://${server1}/end` + ]) + + return createWritable(opaque) + } + ) + + t.equal(body.join(''), 'POST') +}) + +t.test('when a Location response header is NOT present', async t => { + const redirectCodes = [300, 301, 302, 303, 307, 308] + const server = await startRedirectingWithoutLocationServer(t) + + for (const code of redirectCodes) { + t.test(`should return the original response after a HTTP ${code}`, async t => { + t.plan(3) + + const body = [] + + await stream( + `http://${server}/${code}`, + { opaque: body, maxRedirections: 10 }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, code) + t.notOk(headers.location) + + return createWritable(opaque) + } + ) + + t.equal(body.length, 0) + }) + } +}) + +t.test('should not follow redirects when using Readable request bodies', async t => { + t.plan(3) + + const body = [] + const server = await startRedirectingServer(t) + + await stream( + `http://${server}`, + { + method: 'POST', + body: createReadable('REQUEST'), + opaque: body, + maxRedirections: 10 + }, + ({ statusCode, headers, opaque }) => { + t.equal(statusCode, 302) + t.equal(headers.location, `http://${server}/302/1`) + + return createWritable(opaque) + } + ) + + t.equal(body.length, 0) +}) + +t.test('should handle errors', async t => { + t.plan(2) + + const body = [] + + try { + await stream('http://localhost:0', { opaque: body, maxRedirections: 10 }, ({ statusCode, headers, opaque }) => { + return createWritable(opaque) + }) + + throw new Error('Did not throw') + } catch (error) { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + t.equal(body.length, 0) + } +}) + +t.test('removes authorization header on third party origin', async t => { + t.plan(1) + + const body = [] + + const [server1] = await startRedirectingWithAuthorization(t, 'secret') + await stream(`http://${server1}`, { + maxRedirections: 10, + opaque: body, + headers: { + authorization: 'secret' + } + }, ({ statusCode, headers, opaque }) => createWritable(opaque)) + + t.equal(body.length, 0) +}) + +t.test('removes cookie header on third party origin', async t => { + t.plan(1) + + const body = [] + + const [server1] = await startRedirectingWithCookie(t, 'a=b') + await stream(`http://${server1}`, { + maxRedirections: 10, + opaque: body, + headers: { + cookie: 'a=b' + } + }, ({ statusCode, headers, opaque }) => createWritable(opaque)) + + t.equal(body.length, 0) +}) + +t.teardown(() => process.exit()) diff --git a/test/redirect-upgrade.js b/test/redirect-upgrade.js new file mode 100644 index 0000000..dbe5840 --- /dev/null +++ b/test/redirect-upgrade.js @@ -0,0 +1,34 @@ +'use strict' + +const t = require('tap') +const { upgrade } = require('..') +const { startServer } = require('./utils/redirecting-servers') + +t.test('should upgrade the connection when no redirects are present', async t => { + t.plan(2) + + const server = await startServer(t, (req, res) => { + if (req.url === '/') { + res.statusCode = 301 + res.setHeader('Location', `http://${server}/end`) + res.end('REDIRECT') + return + } + + res.statusCode = 101 + res.setHeader('Connection', 'upgrade') + res.setHeader('Upgrade', req.headers.upgrade) + res.end('') + }) + + const { headers, socket } = await upgrade(`http://${server}/`, { + method: 'GET', + protocol: 'foo/1', + maxRedirections: 10 + }) + + socket.end() + + t.equal(headers.connection, 'upgrade') + t.equal(headers.upgrade, 'foo/1') +}) diff --git a/test/request-crlf.js b/test/request-crlf.js new file mode 100644 index 0000000..abcecf0 --- /dev/null +++ b/test/request-crlf.js @@ -0,0 +1,32 @@ +'use strict' + +const { createServer } = require('http') +const { test } = require('tap') +const { request, errors } = require('..') + +test('should validate content-type CRLF Injection', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + t.fail('should not receive any request') + res.statusCode = 200 + res.end('hello') + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + try { + await request(`http://localhost:${server.address().port}`, { + method: 'GET', + headers: { + 'content-type': 'application/json\r\n\r\nGET /foo2 HTTP/1.1' + } + }) + t.fail('request should fail') + } catch (e) { + t.type(e, errors.InvalidArgumentError) + t.equal(e.message, 'invalid content-type header') + } + }) +}) diff --git a/test/request-timeout.js b/test/request-timeout.js new file mode 100644 index 0000000..3ec5c10 --- /dev/null +++ b/test/request-timeout.js @@ -0,0 +1,820 @@ +'use strict' + +const { test } = require('tap') +const { createReadStream, writeFileSync, unlinkSync } = require('fs') +const { Client, errors } = require('..') +const { kConnect } = require('../lib/core/symbols') +const { nodeMajor } = require('../lib/core/util') +const timers = require('../lib/timers') +const { createServer } = require('http') +const EventEmitter = require('events') +const FakeTimers = require('@sinonjs/fake-timers') +const { AbortController } = require('abort-controller') +const { + pipeline, + Readable, + Writable, + PassThrough +} = require('stream') + +test('request timeout', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 1000) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { headersTimeout: 500 }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + }) +}) + +test('request timeout with readable body', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + const tempfile = `${__filename}.10mb.txt` + writeFileSync(tempfile, Buffer.alloc(10 * 1024 * 1024)) + t.teardown(() => unlinkSync(tempfile)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { headersTimeout: 1e3 }) + t.teardown(client.destroy.bind(client)) + + const body = createReadStream(tempfile) + client.request({ path: '/', method: 'POST', body }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + }) +}, { skip: nodeMajor < 14 }) + +test('body timeout', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { bodyTimeout: 50 }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.error(err) + body.on('data', () => { + clock.tick(100) + }).on('error', (err) => { + t.type(err, errors.BodyTimeoutError) + }) + }) + + clock.tick(50) + }) +}) + +test('overridden request timeout', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { headersTimeout: 500 }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', headersTimeout: 50 }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + clock.tick(50) + }) +}) + +test('overridden body timeout', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + res.write('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { bodyTimeout: 500 }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', bodyTimeout: 50 }, (err, { body }) => { + t.error(err) + body.on('data', () => { + clock.tick(100) + }).on('error', (err) => { + t.type(err, errors.BodyTimeoutError) + }) + }) + + clock.tick(50) + }) +}) + +test('With EE signal', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + const ee = new EventEmitter() + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + clock.tick(50) + }) +}) + +test('With abort-controller signal', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + const abortController = new AbortController() + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + clock.tick(50) + }) +}) + +test('Abort before timeout (EE)', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const ee = new EventEmitter() + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + ee.emit('abort') + clock.tick(50) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => { + t.type(err, errors.RequestAbortedError) + clock.tick(100) + }) + }) +}) + +test('Abort before timeout (abort-controller)', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const abortController = new AbortController() + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + abortController.abort() + clock.tick(50) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => { + t.type(err, errors.RequestAbortedError) + clock.tick(100) + }) + }) +}) + +test('Timeout with pipelining', (t) => { + t.plan(3) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(50) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10, + headersTimeout: 50 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + }) +}) + +test('Global option', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + clock.tick(50) + }) +}) + +test('Request options overrides global option', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 100) + clock.tick(100) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 50 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + clock.tick(50) + }) +}) + +test('client.destroy should cancel the timeout', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 100 + }) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.ClientDestroyedError) + }) + + client.destroy(err => { + t.error(err) + }) + }) +}) + +test('client.close should wait for the timeout', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 100 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + client.close((err) => { + t.error(err) + }) + + client.on('connect', () => { + process.nextTick(() => { + clock.tick(100) + }) + }) + }) +}) + +test('Validation', (t) => { + t.plan(4) + + try { + const client = new Client('http://localhost:3000', { + headersTimeout: 'foobar' + }) + t.teardown(client.destroy.bind(client)) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } + + try { + const client = new Client('http://localhost:3000', { + headersTimeout: -1 + }) + t.teardown(client.destroy.bind(client)) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } + + try { + const client = new Client('http://localhost:3000', { + bodyTimeout: 'foobar' + }) + t.teardown(client.destroy.bind(client)) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } + + try { + const client = new Client('http://localhost:3000', { + bodyTimeout: -1 + }) + t.teardown(client.destroy.bind(client)) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('Disable request timeout', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 32e3) + clock.tick(33e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 0, + connectTimeout: 0 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + clock.tick(31e3) + }) +}) + +test('Disable request timeout for a single request', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 32e3) + clock.tick(33e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 0, + connectTimeout: 0 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, response) => { + t.error(err) + const bufs = [] + response.body.on('data', (buf) => { + bufs.push(buf) + }) + response.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + + clock.tick(31e3) + }) +}) + +test('stream timeout', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 301e3) + clock.tick(301e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { connectTimeout: 0 }) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET', + opaque: new PassThrough() + }, (result) => { + t.fail('Should not be called') + }, (err) => { + t.type(err, errors.HeadersTimeoutError) + }) + }) +}) + +test('stream custom timeout', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, 31e3) + clock.tick(31e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 30e3 + }) + t.teardown(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET', + opaque: new PassThrough() + }, (result) => { + t.fail('Should not be called') + }, (err) => { + t.type(err, errors.HeadersTimeoutError) + }) + }) +}) + +test('pipeline timeout', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + req.pipe(res) + }, 301e3) + clock.tick(301e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, (result) => { + t.fail('Should not be called') + }, (e) => { + t.fail('Should not be called') + }), + new Writable({ + write (chunk, encoding, callback) { + callback() + }, + final (callback) { + callback() + } + }), + (err) => { + t.type(err, errors.HeadersTimeoutError) + } + ) + }) +}) + +test('pipeline timeout', (t) => { + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + req.pipe(res) + }, 31e3) + clock.tick(31e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + headersTimeout: 30e3 + }) + t.teardown(client.destroy.bind(client)) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, (result) => { + t.fail('Should not be called') + }, (e) => { + t.fail('Should not be called') + }), + new Writable({ + write (chunk, encoding, callback) { + callback() + }, + final (callback) { + callback() + } + }), + (err) => { + t.type(err, errors.HeadersTimeoutError) + } + ) + }) +}) + +test('client.close should not deadlock', (t) => { + t.plan(2) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 200, + headersTimeout: 100 + }) + t.teardown(client.destroy.bind(client)) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err, response) => { + t.type(err, errors.HeadersTimeoutError) + }) + + client.close((err) => { + t.error(err) + }) + + clock.tick(100) + }) + }) +}) diff --git a/test/request-timeout2.js b/test/request-timeout2.js new file mode 100644 index 0000000..53943fb --- /dev/null +++ b/test/request-timeout2.js @@ -0,0 +1,48 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') + +test('request timeout with slow readable body', (t) => { + t.plan(1) + + const server = createServer(async (req, res) => { + let str = '' + for await (const x of req) { + str += x + } + res.end(str) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { headersTimeout: 50 }) + t.teardown(client.close.bind(client)) + + const body = new Readable({ + read () { + if (this._reading) { + return + } + this._reading = true + + this.push('asd') + setTimeout(() => { + this.push('asd') + this.push(null) + }, 2e3) + } + }) + client.request({ + path: '/', + method: 'POST', + headersTimeout: 1e3, + body + }, async (err, response) => { + t.error(err) + await response.body.dump() + }) + }) +}) diff --git a/test/request.js b/test/request.js new file mode 100644 index 0000000..d3a2f74 --- /dev/null +++ b/test/request.js @@ -0,0 +1,248 @@ +'use strict' + +const { createServer } = require('http') +const { test } = require('tap') +const { request } = require('..') + +test('no-slash/one-slash pathname should be included in req.path', async (t) => { + const pathServer = createServer((req, res) => { + t.fail('it shouldn\'t be called') + res.statusCode = 200 + res.end('hello') + }) + + const requestedServer = createServer((req, res) => { + t.equal(`/localhost:${pathServer.address().port}`, req.url) + t.equal('GET', req.method) + t.equal(`localhost:${requestedServer.address().port}`, req.headers.host) + res.statusCode = 200 + res.end('hello') + }) + + t.teardown(requestedServer.close.bind(requestedServer)) + t.teardown(pathServer.close.bind(pathServer)) + + await Promise.all([ + requestedServer.listen(0), + pathServer.listen(0) + ]) + + const noSlashPathname = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + pathname: `localhost:${pathServer.address().port}` + }) + t.equal(noSlashPathname.statusCode, 200) + const noSlashPath = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + path: `localhost:${pathServer.address().port}` + }) + t.equal(noSlashPath.statusCode, 200) + const noSlashPath2Arg = await request( + `http://localhost:${requestedServer.address().port}`, + { path: `localhost:${pathServer.address().port}` } + ) + t.equal(noSlashPath2Arg.statusCode, 200) + const oneSlashPathname = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + pathname: `/localhost:${pathServer.address().port}` + }) + t.equal(oneSlashPathname.statusCode, 200) + const oneSlashPath = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + path: `/localhost:${pathServer.address().port}` + }) + t.equal(oneSlashPath.statusCode, 200) + const oneSlashPath2Arg = await request( + `http://localhost:${requestedServer.address().port}`, + { path: `/localhost:${pathServer.address().port}` } + ) + t.equal(oneSlashPath2Arg.statusCode, 200) + t.end() +}) + +test('protocol-relative URL as pathname should be included in req.path', async (t) => { + const pathServer = createServer((req, res) => { + t.fail('it shouldn\'t be called') + res.statusCode = 200 + res.end('hello') + }) + + const requestedServer = createServer((req, res) => { + t.equal(`//localhost:${pathServer.address().port}`, req.url) + t.equal('GET', req.method) + t.equal(`localhost:${requestedServer.address().port}`, req.headers.host) + res.statusCode = 200 + res.end('hello') + }) + + t.teardown(requestedServer.close.bind(requestedServer)) + t.teardown(pathServer.close.bind(pathServer)) + + await Promise.all([ + requestedServer.listen(0), + pathServer.listen(0) + ]) + + const noSlashPathname = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + pathname: `//localhost:${pathServer.address().port}` + }) + t.equal(noSlashPathname.statusCode, 200) + const noSlashPath = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + path: `//localhost:${pathServer.address().port}` + }) + t.equal(noSlashPath.statusCode, 200) + const noSlashPath2Arg = await request( + `http://localhost:${requestedServer.address().port}`, + { path: `//localhost:${pathServer.address().port}` } + ) + t.equal(noSlashPath2Arg.statusCode, 200) + t.end() +}) + +test('Absolute URL as pathname should be included in req.path', async (t) => { + const pathServer = createServer((req, res) => { + t.fail('it shouldn\'t be called') + res.statusCode = 200 + res.end('hello') + }) + + const requestedServer = createServer((req, res) => { + t.equal(`/http://localhost:${pathServer.address().port}`, req.url) + t.equal('GET', req.method) + t.equal(`localhost:${requestedServer.address().port}`, req.headers.host) + res.statusCode = 200 + res.end('hello') + }) + + t.teardown(requestedServer.close.bind(requestedServer)) + t.teardown(pathServer.close.bind(pathServer)) + + await Promise.all([ + requestedServer.listen(0), + pathServer.listen(0) + ]) + + const noSlashPathname = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + pathname: `http://localhost:${pathServer.address().port}` + }) + t.equal(noSlashPathname.statusCode, 200) + const noSlashPath = await request({ + method: 'GET', + origin: `http://localhost:${requestedServer.address().port}`, + path: `http://localhost:${pathServer.address().port}` + }) + t.equal(noSlashPath.statusCode, 200) + const noSlashPath2Arg = await request( + `http://localhost:${requestedServer.address().port}`, + { path: `http://localhost:${pathServer.address().port}` } + ) + t.equal(noSlashPath2Arg.statusCode, 200) + t.end() +}) + +test('DispatchOptions#reset', scope => { + scope.plan(4) + + scope.test('Should throw if invalid reset option', t => { + t.plan(1) + + t.rejects(request({ + method: 'GET', + origin: 'http://somehost.xyz', + reset: 0 + }), 'invalid reset') + }) + + scope.test('Should include "connection:close" if reset true', async t => { + const server = createServer((req, res) => { + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(req.headers.connection, 'close') + res.statusCode = 200 + res.end('hello') + }) + + t.plan(3) + + t.teardown(server.close.bind(server)) + + await new Promise((resolve, reject) => { + server.listen(0, (err) => { + if (err != null) reject(err) + else resolve() + }) + }) + + await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + reset: true + }) + }) + + scope.test('Should include "connection:keep-alive" if reset false', async t => { + const server = createServer((req, res) => { + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(req.headers.connection, 'keep-alive') + res.statusCode = 200 + res.end('hello') + }) + + t.plan(3) + + t.teardown(server.close.bind(server)) + + await new Promise((resolve, reject) => { + server.listen(0, (err) => { + if (err != null) reject(err) + else resolve() + }) + }) + + await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + reset: false + }) + }) + + scope.test('Should react to manual set of "connection:close" header', async t => { + const server = createServer((req, res) => { + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(req.headers.connection, 'close') + res.statusCode = 200 + res.end('hello') + }) + + t.plan(3) + + t.teardown(server.close.bind(server)) + + await new Promise((resolve, reject) => { + server.listen(0, (err) => { + if (err != null) reject(err) + else resolve() + }) + }) + + await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + headers: { + connection: 'close' + } + }) + }) +}) diff --git a/test/retry-handler.js b/test/retry-handler.js new file mode 100644 index 0000000..a4577a6 --- /dev/null +++ b/test/retry-handler.js @@ -0,0 +1,622 @@ +'use strict' +const { createServer } = require('node:http') +const { once } = require('node:events') + +const tap = require('tap') + +const { RetryHandler, Client } = require('..') +const { RequestHandler } = require('../lib/api/api-request') + +tap.test('Should retry status code', t => { + let counter = 0 + const chunks = [] + const server = createServer() + const dispatchOptions = { + retryOptions: { + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + }, + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + t.plan(4) + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + return + case 1: + res.writeHead(500) + res.end('failed') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.equal(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + t.equal(counter, 2) + }, + onError () { + t.fail() + } + } + }) + + t.teardown(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + client.dispatch( + { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + }) +}) + +tap.test('Should use retry-after header for retries', t => { + let counter = 0 + const chunks = [] + const server = createServer() + let checkpoint + const dispatchOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + t.plan(4) + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': 1 + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 500) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.equal(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.error(err) + } + } + }) + + t.teardown(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + client.dispatch( + { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + }) +}) + +tap.test('Should use retry-after header for retries (date)', t => { + let counter = 0 + const chunks = [] + const server = createServer() + let checkpoint + const dispatchOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + t.plan(4) + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': new Date( + new Date().setSeconds(new Date().getSeconds() + 1) + ).toUTCString() + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 1) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.equal(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.error(err) + } + } + }) + + t.teardown(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + client.dispatch( + { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + }) +}) + +tap.test('Should retry with defaults', t => { + let counter = 0 + const chunks = [] + const server = createServer() + const dispatchOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + counter++ + return + case 1: + res.writeHead(500) + res.end('failed') + counter++ + return + case 2: + res.writeHead(200) + res.end('hello world!') + counter++ + return + default: + t.fail() + } + }) + + t.plan(3) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.equal(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'hello world!') + }, + onError (err) { + t.error(err) + } + } + }) + + t.teardown(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + client.dispatch( + { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + }) +}) + +tap.test('Should handle 206 partial content', t => { + const chunks = [] + let counter = 0 + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.pass() + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.same(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'asd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const dispatchOptions = { + retryOptions: { + retry: function (err, _, done) { + counter++ + + if (err.code && err.code === 'UND_ERR_DESTROYED') { + return done(false) + } + + if (err.statusCode === 206) return done(err) + + setTimeout(done, 800) + } + }, + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + t.plan(8) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: (...args) => { + return client.dispatch(...args) + }, + handler: { + onRequestSent () { + t.pass() + }, + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.equal(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'abcdef') + t.equal(counter, 1) + }, + onError () { + t.fail() + } + } + }) + + client.dispatch( + { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + + t.teardown(async () => { + await client.close() + + server.close() + await once(server, 'close') + }) + }) +}) + +tap.test('Should handle 206 partial content - bad-etag', t => { + const chunks = [] + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.pass() + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.same(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'erwsd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const dispatchOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + t.plan(6) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler( + dispatchOptions, + { + dispatch: (...args) => { + return client.dispatch(...args) + }, + handler: { + onConnect () { + t.pass() + }, + onBodySent () { + t.pass() + }, + onHeaders (status, _rawHeaders, resume, _statusMessage) { + t.pass() + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.error('should not complete') + }, + onError (err) { + t.equal(Buffer.concat(chunks).toString('utf-8'), 'abc') + t.equal(err.code, 'UND_ERR_REQ_RETRY') + } + } + } + ) + + client.dispatch( + { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + + t.teardown(async () => { + await client.close() + + server.close() + await once(server, 'close') + }) + }) +}) + +tap.test('retrying a request with a body', t => { + let counter = 0 + const server = createServer() + const dispatchOptions = { + retryOptions: { + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + }, + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ hello: 'world' }) + } + + t.plan(1) + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + return + case 1: + res.writeHead(500) + res.end('failed') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: new RequestHandler(dispatchOptions, (err, data) => { + t.error(err) + }) + }) + + t.teardown(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + client.dispatch( + { + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ hello: 'world' }) + }, + handler + ) + }) +}) diff --git a/test/socket-back-pressure.js b/test/socket-back-pressure.js new file mode 100644 index 0000000..9e774b3 --- /dev/null +++ b/test/socket-back-pressure.js @@ -0,0 +1,54 @@ +'use strict' + +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const { test } = require('tap') + +test('socket back-pressure', (t) => { + t.plan(3) + + const server = createServer() + let bytesWritten = 0 + + const buf = Buffer.allocUnsafe(16384) + const src = new Readable({ + read () { + bytesWritten += buf.length + this.push(buf) + if (bytesWritten >= 1e6) { + this.push(null) + } + } + }) + + server.on('request', (req, res) => { + src.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + t.teardown(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET', opaque: 'asd' }, (err, data) => { + t.error(err) + data.body + .resume() + .once('data', () => { + data.body.pause() + // TODO: Try to avoid timeout. + setTimeout(() => { + t.ok(data.body._readableState.length < bytesWritten - data.body._readableState.highWaterMark) + src.push(null) + data.body.resume() + }, 1e3) + }) + .on('end', () => { + t.pass() + }) + }) + }) +}) diff --git a/test/socket-timeout.js b/test/socket-timeout.js new file mode 100644 index 0000000..8019c74 --- /dev/null +++ b/test/socket-timeout.js @@ -0,0 +1,100 @@ +'use strict' + +const { test } = require('tap') +const { Client, errors } = require('..') +const timers = require('../lib/timers') +const { createServer } = require('http') +const FakeTimers = require('@sinonjs/fake-timers') + +test('timeout with pipelining 1', (t) => { + t.plan(9) + + const server = createServer() + + server.once('request', (req, res) => { + t.pass('first request received, we are letting this timeout on the client') + + server.once('request', (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1, + headersTimeout: 500, + bodyTimeout: 500 + }) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + opaque: 'asd' + }, (err, data) => { + t.type(err, errors.HeadersTimeoutError) // we are expecting an error + t.equal(data.opaque, 'asd') + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { statusCode, headers, body }) => { + t.error(err) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('Disable socket timeout', (t) => { + t.plan(2) + + const server = createServer() + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + server.once('request', (req, res) => { + setTimeout(() => { + res.end('hello') + }, 31e3) + clock.tick(32e3) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 0 + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, result) => { + t.error(err) + const bufs = [] + result.body.on('data', (buf) => { + bufs.push(buf) + }) + result.body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) diff --git a/test/stream-compat.js b/test/stream-compat.js new file mode 100644 index 0000000..71d2410 --- /dev/null +++ b/test/stream-compat.js @@ -0,0 +1,75 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') +const { Readable } = require('stream') +const EE = require('events') + +test('stream body without destroy', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const signal = new EE() + const body = new Readable({ read () {} }) + body.destroy = undefined + body.on('error', (err) => { + t.ok(err) + }) + client.request({ + path: '/', + method: 'PUT', + signal, + body + }, (err, data) => { + t.ok(err) + }) + signal.emit('abort') + }) +}) + +test('IncomingMessage', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const proxyClient = new Client(`http://localhost:${server.address().port}`) + t.teardown(proxyClient.destroy.bind(proxyClient)) + + const proxy = createServer((req, res) => { + proxyClient.request({ + path: '/', + method: 'PUT', + body: req + }, (err, data) => { + t.error(err) + data.body.pipe(res) + }) + }) + t.teardown(proxy.close.bind(proxy)) + + proxy.listen(0, () => { + const client = new Client(`http://localhost:${proxy.address().port}`) + t.teardown(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'PUT', + body: 'hello world' + }, (err, data) => { + t.error(err) + }) + }) + }) +}) diff --git a/test/tls-client-cert.js b/test/tls-client-cert.js new file mode 100644 index 0000000..8ae301d --- /dev/null +++ b/test/tls-client-cert.js @@ -0,0 +1,70 @@ +'use strict' + +const { readFileSync } = require('fs') +const { join } = require('path') +const https = require('https') +const { test } = require('tap') +const { Client } = require('..') +const { kSocket } = require('../lib/core/symbols') +const { nodeMajor } = require('../lib/core/util') + +const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8'), + requestCert: true, + rejectUnauthorized: false +} + +test('Client using valid client certificate', { skip: nodeMajor > 16 }, t => { + t.plan(5) + + const server = https.createServer(serverOptions, (req, res) => { + const authorized = req.client.authorized + t.ok(authorized) + + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + + server.listen(0, function () { + const tls = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'client-key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'client-crt.pem'), 'utf8'), + rejectUnauthorized: false, + servername: 'agent1' + } + const client = new Client(`https://localhost:${server.address().port}`, { + connect: tls + }) + + t.teardown(() => { + client.close() + server.close() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { statusCode, body }) => { + t.error(err) + t.equal(statusCode, 200) + + const authorized = client[kSocket].authorized + t.ok(authorized) + + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) diff --git a/test/tls-session-reuse.js b/test/tls-session-reuse.js new file mode 100644 index 0000000..ab012f1 --- /dev/null +++ b/test/tls-session-reuse.js @@ -0,0 +1,185 @@ +'use strict' + +const { readFileSync } = require('fs') +const { join } = require('path') +const https = require('https') +const crypto = require('crypto') +const { test, teardown } = require('tap') +const { Client, Pool } = require('..') +const { kSocket } = require('../lib/core/symbols') +const { nodeMajor } = require('../lib/core/util') + +const options = { + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') +} +const ca = readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') + +test('A client should disable session caching', { + skip: nodeMajor < 11 // tls socket session event has been added in Node 11. Cf. https://nodejs.org/api/tls.html#tls_event_session +}, t => { + const clientSessions = {} + let serverRequests = 0 + + t.test('Prepare request', t => { + t.plan(3) + const server = https.createServer(options, (req, res) => { + if (req.url === '/drop-key') { + server.setTicketKeys(crypto.randomBytes(48)) + } + serverRequests++ + res.end() + }) + + server.listen(0, function () { + const tls = { + ca, + rejectUnauthorized: false, + servername: 'agent1' + } + const client = new Client(`https://localhost:${server.address().port}`, { + pipelining: 0, + tls, + maxCachedSessions: 0 + }) + + t.teardown(() => { + client.close() + server.close() + }) + + const queue = [{ + name: 'first', + method: 'GET', + path: '/' + }, { + name: 'second', + method: 'GET', + path: '/' + }] + + function request () { + const options = queue.shift() + if (options.ciphers) { + // Choose different cipher to use different cache entry + tls.ciphers = options.ciphers + } else { + delete tls.ciphers + } + client.request(options, (err, data) => { + t.error(err) + clientSessions[options.name] = client[kSocket].getSession() + data.body.resume().on('end', () => { + if (queue.length !== 0) { + return request() + } + t.pass() + }) + }) + } + request() + }) + }) + + t.test('Verify cached sessions', t => { + t.plan(2) + t.equal(serverRequests, 2) + t.not( + clientSessions.first.toString('hex'), + clientSessions.second.toString('hex') + ) + }) + + t.end() +}) + +test('A pool should be able to reuse TLS sessions between clients', { + skip: nodeMajor < 11 // tls socket session event has been added in Node 11. Cf. https://nodejs.org/api/tls.html#tls_event_session +}, t => { + let serverRequests = 0 + + const REQ_COUNT = 10 + const ASSERT_PERFORMANCE_GAIN = false + + t.test('Prepare request', t => { + t.plan(2 + 1 + (ASSERT_PERFORMANCE_GAIN ? 1 : 0)) + const server = https.createServer(options, (req, res) => { + serverRequests++ + res.end() + }) + + let numSessions = 0 + const sessions = [] + + server.listen(0, async () => { + const poolWithSessionReuse = new Pool(`https://localhost:${server.address().port}`, { + pipelining: 0, + connections: 100, + maxCachedSessions: 1, + tls: { + ca, + rejectUnauthorized: false, + servername: 'agent1' + } + }) + const poolWithoutSessionReuse = new Pool(`https://localhost:${server.address().port}`, { + pipelining: 0, + connections: 100, + maxCachedSessions: 0, + tls: { + ca, + rejectUnauthorized: false, + servername: 'agent1' + } + }) + + poolWithSessionReuse.on('connect', (url, targets) => { + const y = targets[1][kSocket].getSession() + if (sessions.some(x => x.equals(y))) { + return + } + sessions.push(y) + numSessions++ + }) + + t.teardown(() => { + poolWithSessionReuse.close() + poolWithoutSessionReuse.close() + server.close() + }) + + function request (pool, expectTLSSessionCache) { + return new Promise((resolve, reject) => { + pool.request({ + method: 'GET', + path: '/' + }, (err, data) => { + if (err) return reject(err) + data.body.resume().on('end', resolve) + }) + }) + } + + async function runRequests (pool, numIterations, expectTLSSessionCache) { + const requests = [] + // For the session reuse, we first need one client to connect to receive a valid tls session to reuse + await request(pool, false) + while (numIterations--) { + requests.push(request(pool, expectTLSSessionCache)) + } + return await Promise.all(requests) + } + + await runRequests(poolWithoutSessionReuse, REQ_COUNT, false) + await runRequests(poolWithSessionReuse, REQ_COUNT, true) + + t.equal(numSessions, 2) + t.equal(serverRequests, 2 + REQ_COUNT * 2) + t.pass() + }) + }) + + t.end() +}) + +teardown(() => process.exit()) diff --git a/test/tls.js b/test/tls.js new file mode 100644 index 0000000..fbe07b0 --- /dev/null +++ b/test/tls.js @@ -0,0 +1,188 @@ +'use strict' + +// TODO: Don't depend on external URLs. + +// const { test } = require('tap') +// const { Client } = require('..') +// const { kSocket } = require('../lib/core/symbols') +// const { Readable } = require('stream') +// const { kRunning } = require('../lib/core/symbols') + +// test('tls get 1', (t) => { +// t.plan(4) + +// const client = new Client('https://www.github.com') +// t.teardown(client.close.bind(client)) + +// client.request({ method: 'GET', path: '/' }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// }) +// }) + +// test('tls get 2', (t) => { +// t.plan(4) + +// const client = new Client('https://140.82.112.4', { +// tls: { +// servername: 'www.github.com' +// } +// }) +// t.teardown(client.close.bind(client)) + +// client.request({ method: 'GET', path: '/' }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// }) +// }) + +// test('tls get 3', (t) => { +// t.plan(8) + +// const client = new Client('https://140.82.112.4') +// t.teardown(client.destroy.bind(client)) + +// let didDisconnect = false +// client.request({ +// method: 'GET', +// path: '/', +// headers: { +// host: 'www.github.com' +// } +// }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// client.once('disconnect', () => { +// t.pass() +// didDisconnect = true +// }) +// }) + +// const body = new Readable({ read () {} }) +// body.on('error', (err) => { +// t.ok(err) +// }) +// client.request({ +// method: 'POST', +// path: '/', +// body, +// headers: { +// host: 'www.asd.com' +// } +// }, (err, data) => { +// t.equal(didDisconnect, true) +// t.ok(err) +// }) +// }) + +// test('tls get 4', (t) => { +// t.plan(9) + +// const client = new Client('https://140.82.112.4', { +// tls: { +// servername: 'www.github.com' +// }, +// pipelining: 2 +// }) +// t.teardown(client.close.bind(client)) + +// client.request({ +// method: 'GET', +// path: '/', +// headers: { +// host: '140.82.112.4' +// } +// }, (err, data) => { +// t.error(err) +// t.equal(client[kRunning], 1) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// client.request({ +// method: 'GET', +// path: '/', +// headers: { +// host: 'www.github.com' +// } +// }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// }) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// }) +// }) + +// test('tls get 5', (t) => { +// t.plan(7) + +// const client = new Client('https://140.82.112.4') +// t.teardown(client.destroy.bind(client)) + +// let didDisconnect = false +// client.request({ +// method: 'GET', +// path: '/', +// headers: { +// host: 'www.github.com' +// } +// }, (err, data) => { +// t.error(err) +// t.equal(data.statusCode, 301) +// t.equal(client[kSocket].authorized, true) + +// data.body +// .resume() +// .on('end', () => { +// t.pass() +// }) +// client.once('disconnect', () => { +// t.pass() +// didDisconnect = true +// }) +// }) + +// client.request({ +// method: 'POST', +// path: '/', +// body: [], +// headers: { +// host: 'www.asd.com' +// } +// }, (err, data) => { +// t.equal(didDisconnect, true) +// t.ok(err) +// }) +// }) diff --git a/test/trailers.js b/test/trailers.js new file mode 100644 index 0000000..ca56de2 --- /dev/null +++ b/test/trailers.js @@ -0,0 +1,57 @@ +'use strict' + +const { test } = require('tap') +const { Client } = require('..') +const { createServer } = require('http') + +test('response trailers missing is OK', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.writeHead(200, { + Trailer: 'content-length' + }) + res.end('response') + }) + t.teardown(server.close.bind(server)) + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET', + body: 'asd' + }) + + t.equal(await body.text(), 'response') + }) +}) + +test('response trailers missing w trailers is OK', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.writeHead(200, { + Trailer: 'content-length' + }) + res.addTrailers({ + asd: 'foo' + }) + res.end('response') + }) + t.teardown(server.close.bind(server)) + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.destroy.bind(client)) + + const { body, trailers } = await client.request({ + path: '/', + method: 'GET', + body: 'asd' + }) + + t.equal(await body.text(), 'response') + t.same(trailers, { asd: 'foo' }) + }) +}) diff --git a/test/types/agent.test-d.ts b/test/types/agent.test-d.ts new file mode 100644 index 0000000..5e5275f --- /dev/null +++ b/test/types/agent.test-d.ts @@ -0,0 +1,110 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable } from 'tsd' +import { Agent, Dispatcher } from '../..' +import { URL } from 'url' + +expectAssignable(new Agent()) +expectAssignable(new Agent({})) +expectAssignable(new Agent({ maxRedirections: 1 })) +expectAssignable(new Agent({ factory: () => new Dispatcher() })) + +{ + const agent = new Agent() + + // properties + expectAssignable(agent.closed) + expectAssignable(agent.destroyed) + + // request + expectAssignable>(agent.request({ origin: '', path: '', method: 'GET' })) + expectAssignable>(agent.request({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) })) + expectAssignable>(agent.request({ origin: new URL('http://localhost'), path: '', method: 'GET' })) + expectAssignable(agent.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(agent.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // stream + expectAssignable>(agent.stream({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(agent.stream({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(agent.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(agent.stream( + { origin: '', path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(agent.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // pipeline + expectAssignable(agent.pipeline({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(agent.pipeline({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(agent.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // upgrade + expectAssignable>(agent.upgrade({ path: '' })) + expectAssignable(agent.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // connect + expectAssignable>(agent.connect({ path: '' })) + expectAssignable(agent.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // dispatch + expectAssignable(agent.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(agent.dispatch({ origin: '', path: '', method: 'GET', maxRedirections: 1 }, {})) + + // close + expectAssignable>(agent.close()) + expectAssignable(agent.close(() => {})) + + // destroy + expectAssignable>(agent.destroy()) + expectAssignable>(agent.destroy(new Error())) + expectAssignable>(agent.destroy(null)) + expectAssignable(agent.destroy(() => {})) + expectAssignable(agent.destroy(new Error(), () => {})) + expectAssignable(agent.destroy(null, () => {})) +} diff --git a/test/types/api.test-d.ts b/test/types/api.test-d.ts new file mode 100644 index 0000000..c64b131 --- /dev/null +++ b/test/types/api.test-d.ts @@ -0,0 +1,28 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable } from 'tsd' +import { Dispatcher, request, stream, pipeline, connect, upgrade } from '../..' + +// request +expectAssignable>(request('')) +expectAssignable>(request('', { })) +expectAssignable>(request('', { method: 'GET', reset: false })) + +// stream +expectAssignable>(stream('', { method: 'GET' }, data => { + expectAssignable(data) + return new Writable() +})) + +// pipeline +expectAssignable(pipeline('', { method: 'GET' }, data => { + expectAssignable(data) + return new Readable() +})) + +// connect +expectAssignable>(connect('')) +expectAssignable>(connect('', {})) + +// upgrade +expectAssignable>(upgrade('')) +expectAssignable>(upgrade('', {})) diff --git a/test/types/balanced-pool.test-d.ts b/test/types/balanced-pool.test-d.ts new file mode 100644 index 0000000..d7ccf7b --- /dev/null +++ b/test/types/balanced-pool.test-d.ts @@ -0,0 +1,113 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable } from 'tsd' +import { Dispatcher, BalancedPool, Client } from '../..' +import { URL } from 'url' + +expectAssignable(new BalancedPool('')) +expectAssignable(new BalancedPool('', {})) +expectAssignable(new BalancedPool(new URL('http://localhost'), {})) +expectAssignable(new BalancedPool('', { factory: () => new Dispatcher() })) +expectAssignable(new BalancedPool('', { factory: (origin, opts) => new Client(origin, opts) })) +expectAssignable(new BalancedPool('', { connections: 1 })) +expectAssignable(new BalancedPool(['http://localhost:4242', 'http://www.nodejs.org'])) +expectAssignable(new BalancedPool([new URL('http://localhost:4242'),new URL('http://www.nodejs.org')], {})) + +{ + const pool = new BalancedPool('', {}) + + // properties + expectAssignable(pool.closed) + expectAssignable(pool.destroyed) + + // upstreams + expectAssignable(pool.addUpstream('http://www.nodejs.org')) + expectAssignable(pool.removeUpstream('http://www.nodejs.org')) + expectAssignable(pool.addUpstream(new URL('http://www.nodejs.org'))) + expectAssignable(pool.removeUpstream(new URL('http://www.nodejs.org'))) + expectAssignable(pool.upstreams) + + + // request + expectAssignable>(pool.request({ origin: '', path: '', method: 'GET' })) + expectAssignable>(pool.request({ origin: new URL('http://localhost'), path: '', method: 'GET' })) + expectAssignable(pool.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(pool.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // stream + expectAssignable>(pool.stream({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(pool.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(pool.stream( + { origin: '', path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(pool.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // pipeline + expectAssignable(pool.pipeline({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(pool.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // upgrade + expectAssignable>(pool.upgrade({ path: '' })) + expectAssignable(pool.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // connect + expectAssignable>(pool.connect({ path: '' })) + expectAssignable(pool.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // dispatch + expectAssignable(pool.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(pool.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {})) + + // close + expectAssignable>(pool.close()) + expectAssignable(pool.close(() => {})) + + // destroy + expectAssignable>(pool.destroy()) + expectAssignable>(pool.destroy(new Error())) + expectAssignable>(pool.destroy(null)) + expectAssignable(pool.destroy(() => {})) + expectAssignable(pool.destroy(new Error(), () => {})) + expectAssignable(pool.destroy(null, () => {})) +} diff --git a/test/types/cache-storage.test-d.ts b/test/types/cache-storage.test-d.ts new file mode 100644 index 0000000..c21efbd --- /dev/null +++ b/test/types/cache-storage.test-d.ts @@ -0,0 +1,39 @@ +import { expectAssignable } from 'tsd' +import { + caches, + CacheStorage, + Cache, + CacheQueryOptions, + MultiCacheQueryOptions, + RequestInfo, + Request, + Response +} from '../..' + +declare const response: Response +declare const request: Request +declare const options: RequestInfo +declare const cache: Cache + +expectAssignable(caches) +expectAssignable({}) +expectAssignable({ cacheName: 'v1' }) +expectAssignable({ ignoreMethod: false, ignoreSearch: true }) + +expectAssignable({}) +expectAssignable({ ignoreVary: false, ignoreMethod: true, ignoreSearch: true }) + +expectAssignable>(caches.open('v1')) +expectAssignable>(caches.match(options)) +expectAssignable>(caches.match(request)) +expectAssignable>(caches.has('v1')) +expectAssignable>(caches.delete('v1')) +expectAssignable>(caches.keys()) + +expectAssignable>(cache.match(options)) +expectAssignable>(cache.matchAll('v1')) +expectAssignable>(cache.delete('v1')) +expectAssignable>(cache.keys()) +expectAssignable>(cache.add(options)) +expectAssignable>(cache.addAll([options])) +expectAssignable>(cache.put(options, response)) diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts new file mode 100644 index 0000000..c416d77 --- /dev/null +++ b/test/types/client.test-d.ts @@ -0,0 +1,185 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable } from 'tsd' +import { Client, Dispatcher } from '../..' +import { URL } from 'url' + +expectAssignable(new Client('')) +expectAssignable(new Client('', {})) +expectAssignable(new Client('', { + maxRequestsPerClient: 10 +})) +expectAssignable(new Client('', { + connect: { rejectUnauthorized: false } +})) +expectAssignable(new Client(new URL('http://localhost'), {})) + +/** + * Tests for Client.Options: + */ +{ + expectAssignable(new Client('', { + maxHeaderSize: 16384 + })) + expectAssignable(new Client('', { + headersTimeout: 300e3 + })) + expectAssignable(new Client('', { + connectTimeout: 300e3 + })) + expectAssignable(new Client('', { + bodyTimeout: 300e3 + })) + expectAssignable(new Client('', { + keepAliveTimeout: 4e3 + })) + expectAssignable(new Client('', { + keepAliveMaxTimeout: 600e3 + })) + expectAssignable(new Client('', { + keepAliveTimeoutThreshold: 1e3 + })) + expectAssignable(new Client('', { + socketPath: '/var/run/docker.sock' + })) + expectAssignable(new Client('', { + pipelining: 1 + })) + expectAssignable(new Client('', { + strictContentLength: true + })) + expectAssignable(new Client('', { + maxCachedSessions: 1 + })) + expectAssignable(new Client('', { + maxRedirections: 1 + })) + expectAssignable(new Client('', { + maxRequestsPerClient: 1 + })) + expectAssignable(new Client('', { + localAddress: '127.0.0.1' + })) + expectAssignable(new Client('', { + maxResponseSize: -1 + })) + expectAssignable(new Client('', { + autoSelectFamily: true + })) + expectAssignable(new Client('', { + autoSelectFamilyAttemptTimeout: 300e3 + })) + expectAssignable(new Client('', { + interceptors: { + Client: [(dispatcher) => { + expectAssignable(dispatcher); + return (opts, handlers) => { + expectAssignable(opts); + expectAssignable(handlers); + return dispatcher(opts, handlers) + } + }] + } + })) +} + +{ + const client = new Client('') + + // properties + expectAssignable(client.pipelining) + expectAssignable(client.closed) + expectAssignable(client.destroyed) + + // request + expectAssignable>(client.request({ origin: '', path: '', method: 'GET' })) + expectAssignable>(client.request({ origin: new URL('http://localhost:3000'), path: '', method: 'GET' })) + expectAssignable(client.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(client.request({ origin: new URL('http://localhost:3000'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // stream + expectAssignable>(client.stream({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(client.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(client.stream( + { origin: '', path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(client.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // pipeline + expectAssignable(client.pipeline({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(client.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // upgrade + expectAssignable>(client.upgrade({ path: '' })) + expectAssignable>(client.upgrade({ path: '', headers: [] })) + expectAssignable>(client.upgrade({ path: '', headers: {} })) + expectAssignable>(client.upgrade({ path: '', headers: null })) + expectAssignable(client.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // connect + expectAssignable>(client.connect({ path: '' })) + expectAssignable>(client.connect({ path: '', headers: [] })) + expectAssignable>(client.connect({ path: '', headers: {} })) + expectAssignable>(client.connect({ path: '', headers: null })) + expectAssignable(client.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // dispatch + expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: [] }, {})) + expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: {} }, {})) + expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: null }, {})) + expectAssignable(client.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {})) + + // close + expectAssignable>(client.close()) + expectAssignable(client.close(() => {})) + + // destroy + expectAssignable>(client.destroy()) + expectAssignable>(client.destroy(new Error())) + expectAssignable>(client.destroy(null)) + expectAssignable(client.destroy(() => {})) + expectAssignable(client.destroy(new Error(), () => {})) + expectAssignable(client.destroy(null, () => {})) +} diff --git a/test/types/connector.test-d.ts b/test/types/connector.test-d.ts new file mode 100644 index 0000000..9236569 --- /dev/null +++ b/test/types/connector.test-d.ts @@ -0,0 +1,38 @@ +import {expectAssignable} from 'tsd' +import { Client, buildConnector } from '../..' +import {ConnectionOptions, TLSSocket} from 'tls' +import {Socket} from 'net' +import {IpcNetConnectOpts, NetConnectOpts, TcpNetConnectOpts} from "net"; + +const connector = buildConnector({ rejectUnauthorized: false, allowH2: false }) +expectAssignable(new Client('', { + connect (opts: buildConnector.Options, cb: buildConnector.Callback) { + connector(opts, (...args) => { + if (args[0]) { + return cb(args[0], null) + } + if (args[1] instanceof TLSSocket) { + if (args[1].getPeerCertificate().fingerprint256 !== 'FO:OB:AR') { + args[1].destroy() + return cb(new Error('Fingerprint does not match'), null) + } + } + return cb(null, args[1]) + }) + } +})) + +expectAssignable({ + checkServerIdentity: () => undefined, // Test if ConnectionOptions is assignable + localPort: 1234, // Test if TcpNetConnectOpts is assignable + keepAlive: true, + keepAliveInitialDelay: 12345, +}); + +expectAssignable({ + protocol: "http", + hostname: "example.com", + port: "", + localAddress: "127.0.0.1", + httpSocket: new Socket(), +}); diff --git a/test/types/diagnostics-channel.test-d.ts b/test/types/diagnostics-channel.test-d.ts new file mode 100644 index 0000000..334404c --- /dev/null +++ b/test/types/diagnostics-channel.test-d.ts @@ -0,0 +1,72 @@ +import { Socket } from "net"; +import { expectAssignable } from "tsd"; +import { DiagnosticsChannel, buildConnector } from "../.."; + +const request = { + origin: "", + completed: true, + method: "GET" as const, + path: "", + headers: "", + addHeader: (key: string, value: string) => { + return request; + }, +}; + +const response = { + statusCode: 200, + statusText: "OK", + headers: [Buffer.from(""), Buffer.from("")], +}; + +const connectParams = { + host: "", + hostname: "", + protocol: "", + port: "", + servername: "", +}; + +expectAssignable({ request }); +expectAssignable({ request }); +expectAssignable({ + request, + response, +}); +expectAssignable({ + request, + trailers: [Buffer.from(""), Buffer.from("")], +}); +expectAssignable({ + request, + error: new Error("Error"), +}); +expectAssignable({ + request, + headers: "", + socket: new Socket(), +}); +expectAssignable({ + connectParams, + connector: ( + options: buildConnector.Options, + callback: buildConnector.Callback + ) => new Socket(), +}); +expectAssignable({ + socket: new Socket(), + connectParams, + connector: ( + options: buildConnector.Options, + callback: buildConnector.Callback + ) => new Socket(), +}); +expectAssignable({ + error: new Error("Error"), + socket: new Socket(), + connectParams, + connector: ( + options: buildConnector.Options, + callback: buildConnector.Callback + ) => new Socket(), +}); diff --git a/test/types/dispatcher.events.test-d.ts b/test/types/dispatcher.events.test-d.ts new file mode 100644 index 0000000..71057e7 --- /dev/null +++ b/test/types/dispatcher.events.test-d.ts @@ -0,0 +1,45 @@ +import { Dispatcher } from '../..' +import {expectAssignable} from "tsd"; +import {URL} from "url"; +import Errors from "../../types/errors"; + +interface EventHandler { + connect(origin: URL, targets: readonly Dispatcher[]): void + disconnect(origin: URL, targets: readonly Dispatcher[], error: Errors.UndiciError): void + connectionError(origin: URL, targets: readonly Dispatcher[], error: Errors.UndiciError): void + drain(origin: URL): void +} + +{ + const dispatcher = new Dispatcher() + const eventHandler: EventHandler = {} as EventHandler + + expectAssignable(dispatcher.rawListeners('connect')) + expectAssignable(dispatcher.rawListeners('disconnect')) + expectAssignable(dispatcher.rawListeners('connectionError')) + expectAssignable(dispatcher.rawListeners('drain')) + + expectAssignable(dispatcher.listeners('connect')) + expectAssignable(dispatcher.listeners('disconnect')) + expectAssignable(dispatcher.listeners('connectionError')) + expectAssignable(dispatcher.listeners('drain')) + + const eventHandlerMethods: ['on', 'once', 'off', 'addListener', "removeListener", "prependListener", "prependOnceListener"] + = ['on', 'once', 'off', 'addListener', "removeListener", "prependListener", "prependOnceListener"] + + for (const method of eventHandlerMethods) { + expectAssignable(dispatcher[method]('connect', eventHandler["connect"])) + expectAssignable(dispatcher[method]('disconnect', eventHandler["disconnect"])) + expectAssignable(dispatcher[method]('connectionError', eventHandler["connectionError"])) + expectAssignable(dispatcher[method]('drain', eventHandler["drain"])) + } + + const origin = new URL('') + const targets = new Array() + const error = new Errors.UndiciError() + expectAssignable(dispatcher.emit('connect', origin, targets)) + expectAssignable(dispatcher.emit('disconnect', origin, targets, error)) + expectAssignable(dispatcher.emit('connectionError', origin, targets, error)) + expectAssignable(dispatcher.emit('drain', origin)) +} + diff --git a/test/types/dispatcher.test-d.ts b/test/types/dispatcher.test-d.ts new file mode 100644 index 0000000..cd4ebfd --- /dev/null +++ b/test/types/dispatcher.test-d.ts @@ -0,0 +1,123 @@ +import { IncomingHttpHeaders } from 'http' +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable, expectType } from 'tsd' +import { Dispatcher } from '../..' +import { URL } from 'url' +import { Blob } from 'buffer' + +expectAssignable(new Dispatcher()) + +{ + const dispatcher = new Dispatcher() + + const nodeCoreHeaders = { + authorization: undefined, + ['content-type']: 'application/json' + } satisfies IncomingHttpHeaders; + + // dispatch + expectAssignable(dispatcher.dispatch({ path: '', method: 'GET' }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: { authorization: undefined } }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: [] }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: {} }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: nodeCoreHeaders }, {})) + expectAssignable(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: null, reset: true }, {})) + expectAssignable(dispatcher.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {})) + + // connect + expectAssignable>(dispatcher.connect({ path: '', maxRedirections: 0 })) + expectAssignable(dispatcher.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // request + expectAssignable>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0 })) + expectAssignable>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, query: {} })) + expectAssignable>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, query: { pageNum: 1, id: 'abc' } })) + expectAssignable>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, throwOnError: true })) + expectAssignable>(dispatcher.request({ origin: new URL('http://localhost'), path: '', method: 'GET' })) + expectAssignable(dispatcher.request({ origin: '', path: '', method: 'GET', reset: true }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(dispatcher.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // pipeline + expectAssignable(dispatcher.pipeline({ origin: '', path: '', method: 'GET', maxRedirections: 0 }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(dispatcher.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // stream + expectAssignable>(dispatcher.stream({ origin: '', path: '', method: 'GET', maxRedirections: 0 }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(dispatcher.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(dispatcher.stream( + { origin: '', path: '', method: 'GET', reset: false }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(dispatcher.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // upgrade + expectAssignable>(dispatcher.upgrade({ path: '', maxRedirections: 0 })) + expectAssignable(dispatcher.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // close + expectAssignable>(dispatcher.close()) + expectAssignable(dispatcher.close(() => {})) + + // destroy + expectAssignable>(dispatcher.destroy()) + expectAssignable>(dispatcher.destroy(new Error())) + expectAssignable>(dispatcher.destroy(null)) + expectAssignable(dispatcher.destroy(() => {})) + expectAssignable(dispatcher.destroy(new Error(), () => {})) + expectAssignable(dispatcher.destroy(null, () => {})) +} + +declare const { body }: Dispatcher.ResponseData; + +{ + // body mixin tests + expectType(body.body) + expectType(body.bodyUsed) + expectType>(body.arrayBuffer()) + expectType>(body.blob()) + expectType>(body.formData()) + expectType>(body.text()) + expectType>(body.json()) +} diff --git a/test/types/errors.test-d.ts b/test/types/errors.test-d.ts new file mode 100644 index 0000000..837dbf8 --- /dev/null +++ b/test/types/errors.test-d.ts @@ -0,0 +1,115 @@ +import { expectAssignable } from 'tsd' +import { errors } from '../..' +import Client from '../../types/client' +import { IncomingHttpHeaders } from "../../types/header"; + +expectAssignable(new errors.UndiciError()) +expectAssignable(new errors.UndiciError().name) +expectAssignable(new errors.UndiciError().code) + +expectAssignable(new errors.ConnectTimeoutError()) +expectAssignable(new errors.ConnectTimeoutError()) +expectAssignable<'ConnectTimeoutError'>(new errors.ConnectTimeoutError().name) +expectAssignable<'UND_ERR_CONNECT_TIMEOUT'>(new errors.ConnectTimeoutError().code) + +expectAssignable(new errors.HeadersTimeoutError()) +expectAssignable(new errors.HeadersTimeoutError()) +expectAssignable<'HeadersTimeoutError'>(new errors.HeadersTimeoutError().name) +expectAssignable<'UND_ERR_HEADERS_TIMEOUT'>(new errors.HeadersTimeoutError().code) + +expectAssignable(new errors.HeadersOverflowError()) +expectAssignable(new errors.HeadersOverflowError()) +expectAssignable<'HeadersOverflowError'>(new errors.HeadersOverflowError().name) +expectAssignable<'UND_ERR_HEADERS_OVERFLOW'>(new errors.HeadersOverflowError().code) + +expectAssignable(new errors.BodyTimeoutError()) +expectAssignable(new errors.BodyTimeoutError()) +expectAssignable<'BodyTimeoutError'>(new errors.BodyTimeoutError().name) +expectAssignable<'UND_ERR_BODY_TIMEOUT'>(new errors.BodyTimeoutError().code) + +expectAssignable(new errors.ResponseStatusCodeError()) +expectAssignable(new errors.ResponseStatusCodeError()) +expectAssignable<'ResponseStatusCodeError'>(new errors.ResponseStatusCodeError().name) +expectAssignable<'UND_ERR_RESPONSE_STATUS_CODE'>(new errors.ResponseStatusCodeError().code) +expectAssignable(new errors.ResponseStatusCodeError().status) +expectAssignable(new errors.ResponseStatusCodeError().statusCode) +expectAssignable(new errors.ResponseStatusCodeError().headers) +expectAssignable | string>(new errors.ResponseStatusCodeError().body) + +expectAssignable(new errors.InvalidArgumentError()) +expectAssignable(new errors.InvalidArgumentError()) +expectAssignable<'InvalidArgumentError'>(new errors.InvalidArgumentError().name) +expectAssignable<'UND_ERR_INVALID_ARG'>(new errors.InvalidArgumentError().code) + +expectAssignable(new errors.InvalidReturnValueError()) +expectAssignable(new errors.InvalidReturnValueError()) +expectAssignable<'InvalidReturnValueError'>(new errors.InvalidReturnValueError().name) +expectAssignable<'UND_ERR_INVALID_RETURN_VALUE'>(new errors.InvalidReturnValueError().code) + +expectAssignable(new errors.RequestAbortedError()) +expectAssignable(new errors.RequestAbortedError()) +expectAssignable<'AbortError'>(new errors.RequestAbortedError().name) +expectAssignable<'UND_ERR_ABORTED'>(new errors.RequestAbortedError().code) + +expectAssignable(new errors.InformationalError()) +expectAssignable(new errors.InformationalError()) +expectAssignable<'InformationalError'>(new errors.InformationalError().name) +expectAssignable<'UND_ERR_INFO'>(new errors.InformationalError().code) + +expectAssignable(new errors.RequestContentLengthMismatchError()) +expectAssignable(new errors.RequestContentLengthMismatchError()) +expectAssignable<'RequestContentLengthMismatchError'>(new errors.RequestContentLengthMismatchError().name) +expectAssignable<'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'>(new errors.RequestContentLengthMismatchError().code) + +expectAssignable(new errors.ResponseContentLengthMismatchError()) +expectAssignable(new errors.ResponseContentLengthMismatchError()) +expectAssignable<'ResponseContentLengthMismatchError'>(new errors.ResponseContentLengthMismatchError().name) +expectAssignable<'UND_ERR_RES_CONTENT_LENGTH_MISMATCH'>(new errors.ResponseContentLengthMismatchError().code) + +expectAssignable(new errors.ClientDestroyedError()) +expectAssignable(new errors.ClientDestroyedError()) +expectAssignable<'ClientDestroyedError'>(new errors.ClientDestroyedError().name) +expectAssignable<'UND_ERR_DESTROYED'>(new errors.ClientDestroyedError().code) + +expectAssignable(new errors.ClientClosedError()) +expectAssignable(new errors.ClientClosedError()) +expectAssignable<'ClientClosedError'>(new errors.ClientClosedError().name) +expectAssignable<'UND_ERR_CLOSED'>(new errors.ClientClosedError().code) + +expectAssignable(new errors.SocketError()) +expectAssignable(new errors.SocketError()) +expectAssignable<'SocketError'>(new errors.SocketError().name) +expectAssignable<'UND_ERR_SOCKET'>(new errors.SocketError().code) +expectAssignable(new errors.SocketError().socket) + +expectAssignable(new errors.NotSupportedError()) +expectAssignable(new errors.NotSupportedError()) +expectAssignable<'NotSupportedError'>(new errors.NotSupportedError().name) +expectAssignable<'UND_ERR_NOT_SUPPORTED'>(new errors.NotSupportedError().code) + +expectAssignable(new errors.BalancedPoolMissingUpstreamError()) +expectAssignable(new errors.BalancedPoolMissingUpstreamError()) +expectAssignable<'MissingUpstreamError'>(new errors.BalancedPoolMissingUpstreamError().name) +expectAssignable<'UND_ERR_BPL_MISSING_UPSTREAM'>(new errors.BalancedPoolMissingUpstreamError().code) + +expectAssignable(new errors.HTTPParserError()) +expectAssignable(new errors.HTTPParserError()) +expectAssignable<'HTTPParserError'>(new errors.HTTPParserError().name) + +expectAssignable(new errors.ResponseExceededMaxSizeError()) +expectAssignable(new errors.ResponseExceededMaxSizeError()) +expectAssignable<'ResponseExceededMaxSizeError'>(new errors.ResponseExceededMaxSizeError().name) +expectAssignable<'UND_ERR_RES_EXCEEDED_MAX_SIZE'>(new errors.ResponseExceededMaxSizeError().code) + +{ + // @ts-ignore + function f (): errors.HeadersTimeoutError | errors.ConnectTimeoutError { return } + + const e = f() + + if (e.code === 'UND_ERR_HEADERS_TIMEOUT') { + expectAssignable(e) + } else if (e.code === 'UND_ERR_CONNECT_TIMEOUT') { + expectAssignable(e) + } +} diff --git a/test/types/fetch.test-d.ts b/test/types/fetch.test-d.ts new file mode 100644 index 0000000..59fb49f --- /dev/null +++ b/test/types/fetch.test-d.ts @@ -0,0 +1,173 @@ +import { URL } from 'url' +import { Blob } from 'buffer' +import { ReadableStream } from 'stream/web' +import { expectType, expectError, expectAssignable, expectNotAssignable } from 'tsd' +import { + Agent, + BodyInit, + fetch, + FormData, + Headers, + HeadersInit, + SpecIterableIterator, + Request, + RequestCache, + RequestCredentials, + RequestDestination, + RequestInit, + RequestMode, + RequestRedirect, + Response, + ResponseInit, + ResponseType, + ReferrerPolicy +} from '../..' + +const requestInit: RequestInit = {} +const responseInit: ResponseInit = { status: 200, statusText: 'OK' } +const requestInit2: RequestInit = { + dispatcher: new Agent() +} +const requestInit3: RequestInit = {} +// Test assignment. See https://github.com/whatwg/fetch/issues/1445 +requestInit3.credentials = 'include' + +declare const request: Request +declare const headers: Headers +declare const response: Response + +expectType(requestInit.method) +expectType(requestInit.keepalive) +expectType(requestInit.headers) +expectType(requestInit.body) +expectType(requestInit.redirect) +expectType(requestInit.integrity) +expectType(requestInit.signal) +expectType(requestInit.credentials) +expectType(requestInit.mode) +expectType(requestInit.referrer); +expectType(requestInit.referrerPolicy) +expectType(requestInit.window) + +expectType(responseInit.status) +expectType(responseInit.statusText) +expectType(responseInit.headers) + +expectType(new Headers()) +expectType(new Headers({})) +expectType(new Headers([])) +expectType(new Headers(headers)) +expectType(new Headers(undefined)) + +expectType(new Request(request)) +expectType(new Request('https://example.com')) +expectType(new Request(new URL('https://example.com'))) +expectType(new Request(request, requestInit)) +expectType(new Request('https://example.com', requestInit)) +expectType(new Request(new URL('https://example.com'), requestInit)) + +expectType>(fetch(request)) +expectType>(fetch('https://example.com')) +expectType>(fetch(new URL('https://example.com'))) +expectType>(fetch(request, requestInit)) +expectType>(fetch('https://example.com', requestInit)) +expectType>(fetch(new URL('https://example.com'), requestInit)) + +expectType(new Response()) +expectType(new Response(null)) +expectType(new Response('string')) +expectType(new Response(new Blob([]))) +expectType(new Response(new FormData())) +expectType(new Response(new Int8Array())) +expectType(new Response(new Uint8Array())) +expectType(new Response(new Uint8ClampedArray())) +expectType(new Response(new Int16Array())) +expectType(new Response(new Uint16Array())) +expectType(new Response(new Int32Array())) +expectType(new Response(new Uint32Array())) +expectType(new Response(new Float32Array())) +expectType(new Response(new Float64Array())) +expectType(new Response(new BigInt64Array())) +expectType(new Response(new BigUint64Array())) +expectType(new Response(new ArrayBuffer(0))) +expectType(new Response(null, responseInit)) +expectType(new Response('string', responseInit)) +expectType(new Response(new Blob([]), responseInit)) +expectType(new Response(new FormData(), responseInit)) +expectType(new Response(new Int8Array(), responseInit)) +expectType(new Response(new Uint8Array(), responseInit)) +expectType(new Response(new Uint8ClampedArray(), responseInit)) +expectType(new Response(new Int16Array(), responseInit)) +expectType(new Response(new Uint16Array(), responseInit)) +expectType(new Response(new Int32Array(), responseInit)) +expectType(new Response(new Uint32Array(), responseInit)) +expectType(new Response(new Float32Array(), responseInit)) +expectType(new Response(new Float64Array(), responseInit)) +expectType(new Response(new BigInt64Array(), responseInit)) +expectType(new Response(new BigUint64Array(), responseInit)) +expectType(new Response(new ArrayBuffer(0), responseInit)) +expectType(Response.error()) +expectType(Response.json({ a: 'b' })) +expectType(Response.json({}, { status: 200 })) +expectType(Response.json({}, { statusText: 'OK' })) +expectType(Response.json({}, { headers: {} })) +expectType(Response.json(null)) +expectType(Response.redirect('https://example.com', 301)) +expectType(Response.redirect('https://example.com', 302)) +expectType(Response.redirect('https://example.com', 303)) +expectType(Response.redirect('https://example.com', 307)) +expectType(Response.redirect('https://example.com', 308)) +expectError(Response.redirect('https://example.com', NaN)) +expectError(Response.json()) +expectError(Response.json(null, 3)) + +expectType(headers.append('key', 'value')) +expectType(headers.delete('key')) +expectType(headers.get('key')) +expectType(headers.has('key')) +expectType(headers.set('key', 'value')) +expectType>(headers.keys()) +expectType>(headers.values()) +expectType>(headers.entries()) + +expectType(request.cache) +expectType(request.credentials) +expectType(request.destination) +expectType(request.headers) +expectType(request.integrity) +expectType(request.method) +expectType(request.mode) +expectType(request.redirect) +expectType(request.referrerPolicy) +expectType(request.url) +expectType(request.keepalive) +expectType(request.signal) +expectType(request.bodyUsed) +expectType>(request.arrayBuffer()) +expectType>(request.blob()) +expectType>(request.formData()) +expectType>(request.json()) +expectType>(request.text()) +expectType(request.clone()) + +expectType(response.headers) +expectType(response.ok) +expectType(response.status) +expectType(response.statusText) +expectType(response.type) +expectType(response.url) +expectType(response.redirected) +expectType(response.body) +expectType(response.bodyUsed) +expectType>(response.arrayBuffer()) +expectType>(response.blob()) +expectType>(response.formData()) +expectType>(response.json()) +expectType>(response.text()) +expectType(response.clone()) + +expectType(new Request('https://example.com', { body: 'Hello, world', duplex: 'half' })) +expectAssignable({ duplex: 'half' }) +expectNotAssignable({ duplex: 'not valid' }) + +expectType(headers.getSetCookie()) diff --git a/test/types/formdata.test-d.ts b/test/types/formdata.test-d.ts new file mode 100644 index 0000000..79058c4 --- /dev/null +++ b/test/types/formdata.test-d.ts @@ -0,0 +1,27 @@ +import { Blob } from 'buffer' +import { Readable } from 'stream' +import { expectAssignable, expectType } from 'tsd' +import { File, FormData, SpecIterableIterator } from '../..' +import Dispatcher from '../../types/dispatcher' + +declare const dispatcherOptions: Dispatcher.DispatchOptions + +declare const blob: Blob +const formData = new FormData() +expectType(formData) + +expectType(formData.append('key', 'value')) +expectType(formData.append('key', blob)) +expectType(formData.set('key', 'value')) +expectType(formData.set('key', blob)) +expectType(formData.get('key')) +expectType(formData.get('key')) +expectType>(formData.getAll('key')) +expectType>(formData.getAll('key')) +expectType(formData.has('key')) +expectType(formData.delete('key')) +expectAssignable>(formData.keys()) +expectAssignable>(formData.values()) +expectAssignable>(formData.entries()) +expectAssignable>(formData[Symbol.iterator]()) +expectAssignable(dispatcherOptions.body) diff --git a/test/types/global-dispatcher.test-d.ts b/test/types/global-dispatcher.test-d.ts new file mode 100644 index 0000000..428b809 --- /dev/null +++ b/test/types/global-dispatcher.test-d.ts @@ -0,0 +1,12 @@ +import { expectAssignable } from 'tsd' +import { setGlobalDispatcher, Dispatcher, getGlobalDispatcher } from '../..' + +{ + expectAssignable(setGlobalDispatcher(new Dispatcher())) + class CustomDispatcher extends Dispatcher {} + expectAssignable(setGlobalDispatcher(new CustomDispatcher())) +} + +{ + expectAssignable(getGlobalDispatcher()) +} diff --git a/test/types/header.test-d.ts b/test/types/header.test-d.ts new file mode 100644 index 0000000..38ac9f6 --- /dev/null +++ b/test/types/header.test-d.ts @@ -0,0 +1,16 @@ +import { IncomingHttpHeaders as CoreIncomingHttpHeaders } from "http"; +import { expectAssignable, expectNotAssignable } from "tsd"; +import { IncomingHttpHeaders } from "../../types/header"; + +const headers = { + authorization: undefined, + ["content-type"]: "application/json", +} satisfies CoreIncomingHttpHeaders; + +expectAssignable(headers); + +// It is why we do not need to add ` | null` to `IncomingHttpHeaders`: +expectNotAssignable({ + authorization: null, + ["content-type"]: "application/json", +}); diff --git a/test/types/index.test-d.ts b/test/types/index.test-d.ts new file mode 100644 index 0000000..3827e61 --- /dev/null +++ b/test/types/index.test-d.ts @@ -0,0 +1,23 @@ +import { expectAssignable } from 'tsd' +import Undici, {Pool, Client, errors, fetch, Interceptable, RedirectHandler, DecoratorHandler, Headers, Response, Request, FormData, File, FileReader} from '../..' +import Dispatcher from "../../types/dispatcher"; + +expectAssignable(new Undici.Pool('', {})) +expectAssignable(new Undici.Client('', {})) +expectAssignable(new Undici.MockAgent().get('')) +expectAssignable(Undici.errors) +expectAssignable(Undici.fetch) +expectAssignable(Undici.Headers) +expectAssignable(Undici.Response) +expectAssignable(Undici.Request) +expectAssignable(Undici.FormData) +expectAssignable(Undici.File) +expectAssignable(Undici.FileReader) + +const client = new Undici.Client('', {}) +const handler: Dispatcher.DispatchHandlers = {} + +expectAssignable(new Undici.RedirectHandler(client, 10, { + path: '/', method: 'GET' +}, handler)) +expectAssignable(new Undici.DecoratorHandler(handler)) diff --git a/test/types/interceptor.test-d.ts b/test/types/interceptor.test-d.ts new file mode 100644 index 0000000..ba242bf --- /dev/null +++ b/test/types/interceptor.test-d.ts @@ -0,0 +1,5 @@ +import {expectAssignable} from "tsd"; +import Undici from "../.."; +import Dispatcher from "../../types/dispatcher"; + +expectAssignable(Undici.createRedirectInterceptor({ maxRedirections: 3 })) diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts new file mode 100644 index 0000000..5f7f968 --- /dev/null +++ b/test/types/mock-agent.test-d.ts @@ -0,0 +1,75 @@ +import {expectAssignable, expectType} from 'tsd' +import {Agent, Dispatcher, MockAgent, MockClient, MockPool, setGlobalDispatcher} from '../..' +import {MockInterceptor} from '../../types/mock-interceptor' +import MockDispatch = MockInterceptor.MockDispatch; + +expectAssignable(new MockAgent()) +expectAssignable(new MockAgent({})) + +{ + const mockAgent = new MockAgent() + expectAssignable(setGlobalDispatcher(mockAgent)) + + // get + expectAssignable(mockAgent.get('')) + expectAssignable(mockAgent.get(new RegExp(''))) + expectAssignable(mockAgent.get((origin) => { + expectAssignable(origin) + return true + })) + expectAssignable(mockAgent.get('')) + + // close + expectAssignable>(mockAgent.close()) + + // deactivate + expectAssignable(mockAgent.deactivate()) + + // activate + expectAssignable(mockAgent.activate()) + + // enableNetConnect + expectAssignable(mockAgent.enableNetConnect()) + expectAssignable(mockAgent.enableNetConnect('')) + expectAssignable(mockAgent.enableNetConnect(new RegExp(''))) + expectAssignable(mockAgent.enableNetConnect((host) => { + expectAssignable(host) + return true + })) + + // disableNetConnect + expectAssignable(mockAgent.disableNetConnect()) + + // dispatch + expectAssignable(mockAgent.dispatch({origin: '', path: '', method: 'GET'}, {})) + + // intercept + expectAssignable((mockAgent.get('foo')).intercept({path: '', method: 'GET'})) +} + +{ + const mockAgent = new MockAgent({connections: 1}) + expectAssignable(setGlobalDispatcher(mockAgent)) + expectAssignable(mockAgent.get('')) +} + +{ + const agent = new Agent() + const mockAgent = new MockAgent({agent}) + expectAssignable(setGlobalDispatcher(mockAgent)) + expectAssignable(mockAgent.get('')) +} + +{ + interface PendingInterceptor extends MockDispatch { + origin: string; + } + + const agent = new MockAgent({agent: new Agent()}) + expectType<() => PendingInterceptor[]>(agent.pendingInterceptors) + expectType<(options?: { + pendingInterceptorsFormatter?: { + format(pendingInterceptors: readonly PendingInterceptor[]): string; + } + }) => void>(agent.assertNoPendingInterceptors) +} diff --git a/test/types/mock-client.test-d.ts b/test/types/mock-client.test-d.ts new file mode 100644 index 0000000..9e92b8e --- /dev/null +++ b/test/types/mock-client.test-d.ts @@ -0,0 +1,43 @@ +import { expectAssignable } from 'tsd' +import { MockAgent, MockClient } from '../..' +import { MockInterceptor } from '../../types/mock-interceptor' + +{ + const mockClient: MockClient = new MockAgent({ connections: 1 }).get('') + + // intercept + expectAssignable(mockClient.intercept({ path: '', method: 'GET' })) + expectAssignable(mockClient.intercept({ path: '', method: 'GET', body: '', headers: { 'User-Agent': '' } })) + expectAssignable(mockClient.intercept({ path: '', method: 'GET', query: { id: 1 } })) + expectAssignable(mockClient.intercept({ path: new RegExp(''), method: new RegExp(''), body: new RegExp(''), headers: { 'User-Agent': new RegExp('') } })) + expectAssignable(mockClient.intercept({ + path: (path) => { + expectAssignable(path) + return true + }, + method: (method) => { + expectAssignable(method) + return true + }, + body: (body) => { + expectAssignable(body) + return true + }, + headers: { + 'User-Agent': (header) => { + expectAssignable(header) + return true + } + } + })) + + // dispatch + expectAssignable(mockClient.dispatch({ origin: '', path: '', method: 'GET' }, {})) + + // close + expectAssignable>(mockClient.close()) +} + +{ + expectAssignable(new MockClient('', {agent: new MockAgent({ connections: 1})})) +} diff --git a/test/types/mock-errors.test-d.ts b/test/types/mock-errors.test-d.ts new file mode 100644 index 0000000..2cf3e5e --- /dev/null +++ b/test/types/mock-errors.test-d.ts @@ -0,0 +1,19 @@ +import { expectAssignable } from 'tsd' +import { mockErrors, errors } from '../..' + +expectAssignable(new mockErrors.MockNotMatchedError()) +expectAssignable(new mockErrors.MockNotMatchedError()) +expectAssignable(new mockErrors.MockNotMatchedError('kaboom')) +expectAssignable<'MockNotMatchedError'>(new mockErrors.MockNotMatchedError().name) +expectAssignable<'UND_MOCK_ERR_MOCK_NOT_MATCHED'>(new mockErrors.MockNotMatchedError().code) + +{ + // @ts-ignore + function f (): mockErrors.MockNotMatchedError { return } + + const e = f() + + if (e.code === 'UND_MOCK_ERR_MOCK_NOT_MATCHED') { + expectAssignable(e) + } +} diff --git a/test/types/mock-interceptor.test-d.ts b/test/types/mock-interceptor.test-d.ts new file mode 100644 index 0000000..24d29e1 --- /dev/null +++ b/test/types/mock-interceptor.test-d.ts @@ -0,0 +1,80 @@ +import { expectAssignable } from 'tsd' +import { MockAgent, MockPool, BodyInit, Dispatcher } from '../..' +import { MockInterceptor, MockScope } from '../../types/mock-interceptor' + +declare const mockResponseCallbackOptions: MockInterceptor.MockResponseCallbackOptions; + +expectAssignable(mockResponseCallbackOptions.body) + +{ + const mockPool: MockPool = new MockAgent().get('') + const mockInterceptor = mockPool.intercept({ path: '', method: 'GET' }) + const mockInterceptorDefaultMethod = mockPool.intercept({ path: '' }) + + // reply + expectAssignable(mockInterceptor.reply(200)) + expectAssignable(mockInterceptor.reply(200, '')) + expectAssignable(mockInterceptor.reply(200, Buffer)) + expectAssignable(mockInterceptor.reply(200, {})) + expectAssignable(mockInterceptor.reply(200, () => ({}))) + expectAssignable(mockInterceptor.reply(200, {}, {})) + expectAssignable(mockInterceptor.reply(200, () => ({}), {})) + expectAssignable(mockInterceptor.reply(200, {}, { headers: { foo: 'bar' }})) + expectAssignable(mockInterceptor.reply(200, () => ({}), { headers: { foo: 'bar' }})) + expectAssignable(mockInterceptor.reply(200, {}, { trailers: { foo: 'bar' }})) + expectAssignable(mockInterceptor.reply(200, () => ({}), { trailers: { foo: 'bar' }})) + expectAssignable>(mockInterceptor.reply<{ foo: string }>(200, { foo: 'bar' })) + expectAssignable>(mockInterceptor.reply<{ foo: string }>(200, () => ({ foo: 'bar' }))) + expectAssignable(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }}))) + expectAssignable(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }, responseOptions: { + headers: { foo: 'bar' } + }}))) + expectAssignable(mockInterceptor.reply((options) => { + expectAssignable(options); + return { statusCode: 200, data: { foo: 'bar'} + }})) + expectAssignable(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }, responseOptions: { + trailers: { foo: 'bar' } + }}))) + mockInterceptor.reply((options) => { + expectAssignable(options.headers); + return { statusCode: 200, data: { foo: 'bar' } } + }) + + // replyWithError + class CustomError extends Error { + hello(): void {} + } + expectAssignable(mockInterceptor.replyWithError(new Error(''))) + expectAssignable(mockInterceptor.replyWithError(new CustomError(''))) + + // defaultReplyHeaders + expectAssignable(mockInterceptor.defaultReplyHeaders({ foo: 'bar' })) + + // defaultReplyTrailers + expectAssignable(mockInterceptor.defaultReplyTrailers({ foo: 'bar' })) + + // replyContentLength + expectAssignable(mockInterceptor.replyContentLength()) +} + +{ + const mockPool: MockPool = new MockAgent().get('') + const mockScope = mockPool.intercept({ path: '', method: 'GET' }).reply(200) + + // delay + expectAssignable(mockScope.delay(1)) + + // persist + expectAssignable(mockScope.persist()) + + // times + expectAssignable(mockScope.times(2)) +} + +{ + const mockPool: MockPool = new MockAgent().get('') + mockPool.intercept({ path: '', method: 'GET', headers: () => true }) + mockPool.intercept({ path: '', method: 'GET', headers: () => false }) + mockPool.intercept({ path: '', method: 'GET', headers: (headers) => Object.keys(headers).includes('authorization') }) +} diff --git a/test/types/mock-pool.test-d.ts b/test/types/mock-pool.test-d.ts new file mode 100644 index 0000000..b51779b --- /dev/null +++ b/test/types/mock-pool.test-d.ts @@ -0,0 +1,42 @@ +import { expectAssignable } from 'tsd' +import { MockAgent, MockPool } from '../..' +import { MockInterceptor } from '../../types/mock-interceptor' + +{ + const mockPool: MockPool = new MockAgent({ connections: 1 }).get('') + + // intercept + expectAssignable(mockPool.intercept({ path: '', method: 'GET' })) + expectAssignable(mockPool.intercept({ path: '', method: 'GET', body: '', headers: { 'User-Agent': '' } })) + expectAssignable(mockPool.intercept({ path: new RegExp(''), method: new RegExp(''), body: new RegExp(''), headers: { 'User-Agent': new RegExp('') } })) + expectAssignable(mockPool.intercept({ + path: (path) => { + expectAssignable(path) + return true + }, + method: (method) => { + expectAssignable(method) + return true + }, + body: (body) => { + expectAssignable(body) + return true + }, + headers: { + 'User-Agent': (header) => { + expectAssignable(header) + return true + } + } + })) + + // dispatch + expectAssignable(mockPool.dispatch({ origin: '', path: '', method: 'GET' }, {})) + + // close + expectAssignable>(mockPool.close()) +} + +{ + expectAssignable(new MockPool('', {agent: new MockAgent({ connections: 1})})) +} diff --git a/test/types/pool.test-d.ts b/test/types/pool.test-d.ts new file mode 100644 index 0000000..c237468 --- /dev/null +++ b/test/types/pool.test-d.ts @@ -0,0 +1,112 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable, expectType } from 'tsd' +import { Dispatcher, Pool, Client } from '../..' +import { URL } from 'url' + +expectAssignable(new Pool('')) +expectAssignable(new Pool('', {})) +expectAssignable(new Pool(new URL('http://localhost'), {})) +expectAssignable(new Pool('', { factory: () => new Dispatcher() })) +expectAssignable(new Pool('', { factory: (origin, opts) => new Client(origin, opts) })) +expectAssignable(new Pool('', { connections: 1 })) + +{ + const pool = new Pool('', {}) + + // properties + expectAssignable(pool.closed) + expectAssignable(pool.destroyed) + expectAssignable(pool.stats) + + // request + expectAssignable>(pool.request({ origin: '', path: '', method: 'GET' })) + expectAssignable>(pool.request({ origin: new URL('http://localhost'), path: '', method: 'GET' })) + expectAssignable(pool.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(pool.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // stream + expectAssignable>(pool.stream({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(pool.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(pool.stream( + { origin: '', path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(pool.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // pipeline + expectAssignable(pool.pipeline({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(pool.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // upgrade + expectAssignable>(pool.upgrade({ path: '' })) + expectAssignable(pool.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // connect + expectAssignable>(pool.connect({ path: '' })) + expectAssignable(pool.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // dispatch + expectAssignable(pool.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(pool.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {})) + + // close + expectAssignable>(pool.close()) + expectAssignable(pool.close(() => {})) + + // destroy + expectAssignable>(pool.destroy()) + expectAssignable>(pool.destroy(new Error())) + expectAssignable>(pool.destroy(null)) + expectAssignable(pool.destroy(() => {})) + expectAssignable(pool.destroy(new Error(), () => {})) + expectAssignable(pool.destroy(null, () => {})) + + // stats + expectType(pool.stats.connected) + expectType(pool.stats.free) + expectType(pool.stats.pending) + expectType(pool.stats.queued) + expectType(pool.stats.running) + expectType(pool.stats.size) +} diff --git a/test/types/proxy-agent.test-d.ts b/test/types/proxy-agent.test-d.ts new file mode 100644 index 0000000..7cc092b --- /dev/null +++ b/test/types/proxy-agent.test-d.ts @@ -0,0 +1,43 @@ +import { expectAssignable } from 'tsd' +import { URL } from 'url' +import { ProxyAgent, setGlobalDispatcher, getGlobalDispatcher, Agent, Pool } from '../..' + +expectAssignable(new ProxyAgent('')) +expectAssignable(new ProxyAgent({ uri: '' })) +expectAssignable( + new ProxyAgent({ + connections: 1, + uri: '', + auth: '', + token: '', + maxRedirections: 1, + factory: (_origin: URL, opts: Object) => new Agent(opts), + requestTls: { + ca: [''], + key: '', + cert: '', + servername: '', + timeout: 1 + }, + proxyTls: { + ca: [''], + key: '', + cert: '', + servername: '', + timeout: 1 + }, + clientFactory: (origin: URL, opts: object) => new Pool(origin, opts) + }) +) + +{ + const proxyAgent = new ProxyAgent('') + expectAssignable(setGlobalDispatcher(proxyAgent)) + expectAssignable(getGlobalDispatcher()) + + // close + expectAssignable>(proxyAgent.close()) + + // dispatch + expectAssignable(proxyAgent.dispatch({ origin: '', path: '', method: 'GET' }, {})) +} diff --git a/test/types/readable.test-d.ts b/test/types/readable.test-d.ts new file mode 100644 index 0000000..d004b70 --- /dev/null +++ b/test/types/readable.test-d.ts @@ -0,0 +1,34 @@ +import { expectAssignable } from 'tsd' +import BodyReadable from '../../types/readable' +import { Blob } from 'buffer' + +expectAssignable(new BodyReadable()) + +{ + const readable = new BodyReadable() + + // dump + expectAssignable>(readable.dump()) + expectAssignable>(readable.dump({ limit: 123 })) + + // text + expectAssignable>(readable.text()) + + // json + expectAssignable>(readable.json()) + + // blob + expectAssignable>(readable.blob()) + + // arrayBuffer + expectAssignable>(readable.arrayBuffer()) + + // formData + expectAssignable>(readable.formData()) + + // bodyUsed + expectAssignable(readable.bodyUsed) + + // body + expectAssignable(readable.body) +} diff --git a/test/unix.js b/test/unix.js new file mode 100644 index 0000000..019f654 --- /dev/null +++ b/test/unix.js @@ -0,0 +1,141 @@ +'use strict' + +const { test } = require('tap') +const { Client, Pool } = require('..') +const http = require('http') +const https = require('https') +const pem = require('https-pem') +const fs = require('fs') + +if (process.platform !== 'win32') { + test('http unix get', (t) => { + t.plan(7) + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal('localhost', req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + try { + fs.unlinkSync('/var/tmp/test3.sock') + } catch (err) { + + } + + server.listen('/var/tmp/test3.sock', () => { + const client = new Client({ + hostname: 'localhost', + protocol: 'http:' + }, { + socketPath: '/var/tmp/test3.sock' + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) + + test('http unix get pool', (t) => { + t.plan(7) + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal('localhost', req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + try { + fs.unlinkSync('/var/tmp/test3.sock') + } catch (err) { + + } + + server.listen('/var/tmp/test3.sock', () => { + const client = new Pool({ + hostname: 'localhost', + protocol: 'http:' + }, { + socketPath: '/var/tmp/test3.sock' + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) + + test('https get with tls opts', (t) => { + t.plan(6) + + const server = https.createServer(pem, (req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + t.teardown(server.close.bind(server)) + + try { + fs.unlinkSync('/var/tmp/test3.sock') + } catch (err) { + + } + + server.listen('/var/tmp/test8.sock', () => { + const client = new Client({ + hostname: 'localhost', + protocol: 'https:' + }, { + socketPath: '/var/tmp/test8.sock', + tls: { + rejectUnauthorized: false + } + }) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + }) +} diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..794c68e --- /dev/null +++ b/test/util.js @@ -0,0 +1,123 @@ +'use strict' + +const t = require('tap') +const { test } = t +const { Stream } = require('stream') +const { EventEmitter } = require('events') + +const util = require('../lib/core/util') +const { InvalidArgumentError } = require('../lib/core/errors') + +test('isStream', (t) => { + t.plan(3) + + const stream = new Stream() + t.ok(util.isStream(stream)) + + const buffer = Buffer.alloc(0) + t.notOk(util.isStream(buffer)) + + const ee = new EventEmitter() + t.notOk(util.isStream(ee)) +}) + +test('getServerName', (t) => { + t.plan(6) + t.equal(util.getServerName('1.1.1.1'), '') + t.equal(util.getServerName('1.1.1.1:443'), '') + t.equal(util.getServerName('example.com'), 'example.com') + t.equal(util.getServerName('example.com:80'), 'example.com') + t.equal(util.getServerName('[2606:4700:4700::1111]'), '') + t.equal(util.getServerName('[2606:4700:4700::1111]:443'), '') +}) + +test('validateHandler', (t) => { + t.plan(9) + + t.throws(() => util.validateHandler(null), InvalidArgumentError, 'handler must be an object') + t.throws(() => util.validateHandler({ + onConnect: null + }), InvalidArgumentError, 'invalid onConnect method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: null + }), InvalidArgumentError, 'invalid onError method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: null + }), InvalidArgumentError, 'invalid onBodySent method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: () => {}, + onHeaders: null + }), InvalidArgumentError, 'invalid onHeaders method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: () => {}, + onHeaders: () => {}, + onData: null + }), InvalidArgumentError, 'invalid onData method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: () => {}, + onHeaders: () => {}, + onData: () => {}, + onComplete: null + }), InvalidArgumentError, 'invalid onComplete method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: () => {}, + onUpgrade: 'null' + }, 'CONNECT'), InvalidArgumentError, 'invalid onUpgrade method') + t.throws(() => util.validateHandler({ + onConnect: () => {}, + onError: () => {}, + onBodySent: () => {}, + onUpgrade: 'null' + }, 'CONNECT', () => {}), InvalidArgumentError, 'invalid onUpgrade method') +}) + +test('parseHeaders', (t) => { + t.plan(6) + t.same(util.parseHeaders(['key', 'value']), { key: 'value' }) + t.same(util.parseHeaders([Buffer.from('key'), Buffer.from('value')]), { key: 'value' }) + t.same(util.parseHeaders(['Key', 'Value']), { key: 'Value' }) + t.same(util.parseHeaders(['Key', 'value', 'key', 'Value']), { key: ['value', 'Value'] }) + t.same(util.parseHeaders(['key', ['value1', 'value2', 'value3']]), { key: ['value1', 'value2', 'value3'] }) + t.same(util.parseHeaders([Buffer.from('key'), [Buffer.from('value1'), Buffer.from('value2'), Buffer.from('value3')]]), { key: ['value1', 'value2', 'value3'] }) +}) + +test('parseRawHeaders', (t) => { + t.plan(1) + t.same(util.parseRawHeaders(['key', 'value', Buffer.from('key'), Buffer.from('value')]), ['key', 'value', 'key', 'value']) +}) + +test('buildURL', { skip: util.nodeMajor >= 12 }, (t) => { + const tests = [ + [{ id: BigInt(123456) }, 'id=123456'], + [{ date: new Date() }, 'date='], + [{ obj: { id: 1 } }, 'obj='], + [{ params: ['a', 'b', 'c'] }, 'params=a¶ms=b¶ms=c'], + [{ bool: true }, 'bool=true'], + [{ number: 123456 }, 'number=123456'], + [{ string: 'hello' }, 'string=hello'], + [{ null: null }, 'null='], + [{ void: undefined }, 'void='], + [{ fn: function () {} }, 'fn='], + [{}, ''] + ] + + const base = 'https://www.google.com' + + for (const [input, output] of tests) { + const expected = `${base}${output ? `?${output}` : output}` + t.equal(util.buildURL(base, input), expected) + } + + t.end() +}) diff --git a/test/utils/async-iterators.js b/test/utils/async-iterators.js new file mode 100644 index 0000000..da7e0a8 --- /dev/null +++ b/test/utils/async-iterators.js @@ -0,0 +1,25 @@ +'use strict' + +async function * wrapWithAsyncIterable (asyncIterable, indefinite = false) { + for await (const chunk of asyncIterable) { + yield chunk + } + if (indefinite) { + await new Promise(() => {}) + } +} + +const STREAM = 'stream' +const ASYNC_ITERATOR = 'async-iterator' +function maybeWrapStream (stream, type) { + if (type === STREAM) { + return stream + } + if (type === ASYNC_ITERATOR) { + return wrapWithAsyncIterable(stream) + } + + throw new Error(`bad input ${type} should be ${STREAM} or ${ASYNC_ITERATOR}`) +} + +module.exports = { wrapWithAsyncIterable, maybeWrapStream, consts: { STREAM, ASYNC_ITERATOR } } diff --git a/test/utils/esm-wrapper.mjs b/test/utils/esm-wrapper.mjs new file mode 100644 index 0000000..51f8572 --- /dev/null +++ b/test/utils/esm-wrapper.mjs @@ -0,0 +1,102 @@ +import { createServer } from 'http' +import tap from 'tap' +import { + Agent, + Client, + errors, + pipeline, + Pool, + request, + connect, + upgrade, + setGlobalDispatcher, + getGlobalDispatcher, + stream +} from '../../index.js' + +const { test } = tap + +test('imported Client works with basic GET', (t) => { + t.plan(10) + + const server = createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + t.equal(undefined, req.headers.foo) + t.equal('bar', req.headers.bar) + t.equal(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + + t.teardown(server.close.bind(server)) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + t.teardown(client.close.bind(client)) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, data) => { + t.error(err) + const { statusCode, headers, body } = data + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) +}) + +test('imported errors work with request args validation', (t) => { + t.plan(2) + + const client = new Client('http://localhost:5000') + + client.request(null, (err) => { + t.type(err, errors.InvalidArgumentError) + }) + + try { + client.request(null, 'asd') + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('imported errors work with request args validation promise', (t) => { + t.plan(1) + + const client = new Client('http://localhost:5000') + + client.request(null).catch((err) => { + t.type(err, errors.InvalidArgumentError) + }) +}) + +test('named exports', (t) => { + t.equal(typeof Client, 'function') + t.equal(typeof Pool, 'function') + t.equal(typeof Agent, 'function') + t.equal(typeof request, 'function') + t.equal(typeof stream, 'function') + t.equal(typeof pipeline, 'function') + t.equal(typeof connect, 'function') + t.equal(typeof upgrade, 'function') + t.equal(typeof setGlobalDispatcher, 'function') + t.equal(typeof getGlobalDispatcher, 'function') + t.end() +}) diff --git a/test/utils/formdata.js b/test/utils/formdata.js new file mode 100644 index 0000000..edd8854 --- /dev/null +++ b/test/utils/formdata.js @@ -0,0 +1,49 @@ +const Busboy = require('@fastify/busboy') + +function parseFormDataString ( + body, + contentType +) { + const cache = { + fileMap: new Map(), + fields: [] + } + + const bb = new Busboy({ + headers: { + 'content-type': contentType + } + }) + + return new Promise((resolve, reject) => { + bb.on('file', (name, file, filename, encoding, mimeType) => { + cache.fileMap.set(name, { data: [], info: { filename, encoding, mimeType } }) + + file.on('data', (data) => { + const old = cache.fileMap.get(name) + + cache.fileMap.set(name, { + data: [...old.data, data], + info: old.info + }) + }).on('end', () => { + const old = cache.fileMap.get(name) + + cache.fileMap.set(name, { + data: Buffer.concat(old.data), + info: old.info + }) + }) + }) + + bb.on('field', (key, value) => cache.fields.push({ key, value })) + bb.on('finish', () => resolve(cache)) + bb.on('error', (e) => reject(e)) + + bb.end(body) + }) +} + +module.exports = { + parseFormDataString +} diff --git a/test/utils/redirecting-servers.js b/test/utils/redirecting-servers.js new file mode 100644 index 0000000..ad8aa58 --- /dev/null +++ b/test/utils/redirecting-servers.js @@ -0,0 +1,265 @@ +'use strict' + +const { createServer } = require('http') + +const isNode20 = process.version.startsWith('v20.') + +function close (server) { + return function () { + return new Promise(resolve => { + if (isNode20) { + server.closeAllConnections() + } + server.close(resolve) + }) + } +} + +function startServer (t, handler) { + return new Promise(resolve => { + const server = createServer(handler) + + server.listen(0, () => { + resolve(`localhost:${server.address().port}`) + }) + + t.teardown(close(server)) + }) +} + +async function startRedirectingServer (t) { + const server = await startServer(t, (req, res) => { + // Parse the path and normalize arguments + let [code, redirections, query] = req.url + .slice(1) + .split(/[/?]/) + + if (req.url.indexOf('?') !== -1 && !query) { + query = redirections + redirections = 0 + } + + code = parseInt(code, 10) + redirections = parseInt(redirections, 10) + + if (isNaN(code) || code < 0) { + code = 302 + } else if (code < 300) { + res.statusCode = code + redirections = 5 + } + + if (isNaN(redirections) || redirections < 0) { + redirections = 0 + } + + // On 303, the method must be GET or HEAD after the first redirect + if (code === 303 && redirections > 0 && req.method !== 'GET' && req.method !== 'HEAD') { + res.statusCode = 400 + res.setHeader('Connection', 'close') + res.end('Did not switch to GET') + return + } + + // End the chain at some point + if (redirections === 5) { + res.setHeader('Connection', 'close') + res.write( + `${req.method} /${redirections}${query ? ` ${query}` : ''} :: ${Object.entries(req.headers) + .map(([k, v]) => `${k}@${v}`) + .join(' ')}` + ) + + if (parseInt(req.headers['content-length']) > 0) { + res.write(' :: ') + req.pipe(res) + } else { + res.end('') + } + + return + } + + // Redirect by default + res.statusCode = code + res.setHeader('Connection', 'close') + res.setHeader('Location', `http://${server}/${code}/${++redirections}${query ? `?${query}` : ''}`) + res.end('') + }) + + return server +} + +async function startRedirectingWithBodyServer (t) { + const server = await startServer(t, (req, res) => { + if (req.url === '/') { + res.statusCode = 301 + res.setHeader('Connection', 'close') + res.setHeader('Location', `http://${server}/end`) + res.end('REDIRECT') + return + } + + res.setHeader('Connection', 'close') + res.end('FINAL') + }) + + return server +} + +function startRedirectingWithoutLocationServer (t) { + return startServer(t, (req, res) => { + // Parse the path and normalize arguments + let [code] = req.url + .slice(1) + .split('/') + .map(r => parseInt(r, 10)) + + if (isNaN(code) || code < 0) { + code = 302 + } + + res.statusCode = code + res.setHeader('Connection', 'close') + res.end('') + }) +} + +async function startRedirectingChainServers (t) { + const server1 = await startServer(t, (req, res) => { + if (req.url === '/') { + res.statusCode = 301 + res.setHeader('Connection', 'close') + res.setHeader('Location', `http://${server2}/`) + res.end('') + return + } + + res.setHeader('Connection', 'close') + res.end(req.method) + }) + + const server2 = await startServer(t, (req, res) => { + res.statusCode = 301 + res.setHeader('Connection', 'close') + + if (req.url === '/') { + res.setHeader('Location', `http://${server3}/`) + } else { + res.setHeader('Location', `http://${server3}/end`) + } + + res.end('') + }) + + const server3 = await startServer(t, (req, res) => { + res.statusCode = 301 + res.setHeader('Connection', 'close') + + if (req.url === '/') { + res.setHeader('Location', `http://${server2}/end`) + } else { + res.setHeader('Location', `http://${server1}/end`) + } + + res.end('') + }) + + return [server1, server2, server3] +} + +async function startRedirectingWithAuthorization (t, authorization) { + const server1 = await startServer(t, (req, res) => { + if (req.headers.authorization !== authorization) { + res.statusCode = 403 + res.setHeader('Connection', 'close') + res.end('') + return + } + + res.statusCode = 301 + res.setHeader('Connection', 'close') + + res.setHeader('Location', `http://${server2}`) + res.end('') + }) + + const server2 = await startServer(t, (req, res) => { + res.end(req.headers.authorization || '') + }) + + return [server1, server2] +} + +async function startRedirectingWithCookie (t, cookie) { + const server1 = await startServer(t, (req, res) => { + if (req.headers.cookie !== cookie) { + res.statusCode = 403 + res.setHeader('Connection', 'close') + res.end('') + return + } + + res.statusCode = 301 + res.setHeader('Connection', 'close') + + res.setHeader('Location', `http://${server2}`) + res.end('') + }) + + const server2 = await startServer(t, (req, res) => { + res.end(req.headers.cookie || '') + }) + + return [server1, server2] +} + +async function startRedirectingWithRelativePath (t) { + const server = await startServer(t, (req, res) => { + res.setHeader('Connection', 'close') + + if (req.url === '/') { + res.statusCode = 301 + res.setHeader('Location', '/absolute/a') + res.end('') + } else if (req.url === '/absolute/a') { + res.statusCode = 301 + res.setHeader('Location', 'b') + res.end('') + } else { + res.statusCode = 200 + res.end(req.url) + } + }) + + return server +} + +async function startRedirectingWithQueryParams (t) { + const server = await startServer(t, (req, res) => { + if (req.url === '/?param1=first') { + res.statusCode = 301 + res.setHeader('Connection', 'close') + res.setHeader('Location', `http://${server}/?param2=second`) + res.end('REDIRECT') + return + } + + res.setHeader('Connection', 'close') + res.end('') + }) + + return server +} + +module.exports = { + startServer, + startRedirectingServer, + startRedirectingWithBodyServer, + startRedirectingWithoutLocationServer, + startRedirectingChainServers, + startRedirectingWithAuthorization, + startRedirectingWithCookie, + startRedirectingWithRelativePath, + startRedirectingWithQueryParams +} diff --git a/test/utils/stream.js b/test/utils/stream.js new file mode 100644 index 0000000..b78ff5c --- /dev/null +++ b/test/utils/stream.js @@ -0,0 +1,48 @@ +'use strict' + +const { Readable, Writable } = require('stream') + +let ReadableStream + +function createReadable (data) { + return new Readable({ + read () { + this.push(Buffer.from(data)) + this.push(null) + } + }) +} + +function createWritable (target) { + return new Writable({ + write (chunk, _, callback) { + target.push(chunk.toString()) + callback() + }, + final (callback) { + callback() + } + }) +} + +class Source { + constructor (data) { + this.data = data + } + + async start (controller) { + this.controller = controller + } + + async pull (controller) { + controller.enqueue(this.data) + controller.close() + } +} + +function createReadableStream (data) { + ReadableStream = require('stream/web').ReadableStream + return new ReadableStream(new Source(data)) +} + +module.exports = { createReadableStream, createReadable, createWritable } diff --git a/test/validations.js b/test/validations.js new file mode 100644 index 0000000..d1b3409 --- /dev/null +++ b/test/validations.js @@ -0,0 +1,63 @@ +'use strict' + +const t = require('tap') +const { test } = t +const { createServer } = require('http') +const { Client, errors } = require('..') + +const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('hello') + t.fail('server should never be called') +}) +t.teardown(server.close.bind(server)) + +server.listen(0, () => { + const url = `http://localhost:${server.address().port}` + + test('path', (t) => { + t.plan(4) + + const client = new Client(url) + t.teardown(client.close.bind(client)) + + client.request({ path: null, method: 'GET' }, (err, res) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'path must be a string') + }) + + client.request({ path: 'aaa', method: 'GET' }, (err, res) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'path must be an absolute URL or start with a slash') + }) + }) + + test('method', (t) => { + t.plan(2) + + const client = new Client(url) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: null }, (err, res) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'method must be a string') + }) + }) + + test('body', (t) => { + t.plan(4) + + const client = new Client(url) + t.teardown(client.close.bind(client)) + + client.request({ path: '/', method: 'POST', body: 42 }, (err, res) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') + }) + + client.request({ path: '/', method: 'POST', body: { hello: 'world' } }, (err, res) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') + }) + }) +}) diff --git a/test/webidl/converters.js b/test/webidl/converters.js new file mode 100644 index 0000000..95bea88 --- /dev/null +++ b/test/webidl/converters.js @@ -0,0 +1,202 @@ +'use strict' + +const { test } = require('tap') +const { webidl } = require('../../lib/fetch/webidl') + +test('sequence', (t) => { + const converter = webidl.sequenceConverter( + webidl.converters.DOMString + ) + + t.same(converter([1, 2, 3]), ['1', '2', '3']) + + t.throws(() => { + converter(3) + }, TypeError, 'disallows non-objects') + + t.throws(() => { + converter(null) + }, TypeError) + + t.throws(() => { + converter(undefined) + }, TypeError) + + t.throws(() => { + converter({}) + }, TypeError, 'no Symbol.iterator') + + t.throws(() => { + converter({ + [Symbol.iterator]: 42 + }) + }, TypeError, 'invalid Symbol.iterator') + + t.throws(() => { + converter(webidl.converters.sequence({ + [Symbol.iterator] () { + return { + next: 'never!' + } + } + })) + }, TypeError, 'invalid generator') + + t.end() +}) + +test('webidl.dictionaryConverter', (t) => { + t.test('arguments', (t) => { + const converter = webidl.dictionaryConverter([]) + + t.throws(() => { + converter(true) + }, TypeError) + + for (const value of [{}, undefined, null]) { + t.doesNotThrow(() => { + converter(value) + }) + } + + t.end() + }) + + t.test('required key', (t) => { + const converter = webidl.dictionaryConverter([ + { + converter: () => true, + key: 'Key', + required: true + } + ]) + + t.throws(() => { + converter({ wrongKey: 'key' }) + }, TypeError) + + t.doesNotThrow(() => { + converter({ Key: 'this key was required!' }) + }) + + t.end() + }) + + t.end() +}) + +test('ArrayBuffer', (t) => { + t.throws(() => { + webidl.converters.ArrayBuffer(true) + }, TypeError) + + t.throws(() => { + webidl.converters.ArrayBuffer({}) + }, TypeError) + + t.throws(() => { + const sab = new SharedArrayBuffer(1024) + webidl.converters.ArrayBuffer(sab, { allowShared: false }) + }, TypeError) + + t.doesNotThrow(() => { + const sab = new SharedArrayBuffer(1024) + webidl.converters.ArrayBuffer(sab) + }) + + t.doesNotThrow(() => { + const ab = new ArrayBuffer(8) + webidl.converters.ArrayBuffer(ab) + }) + + t.end() +}) + +test('TypedArray', (t) => { + t.throws(() => { + webidl.converters.TypedArray(3) + }, TypeError) + + t.throws(() => { + webidl.converters.TypedArray({}) + }, TypeError) + + t.throws(() => { + const uint8 = new Uint8Array([1, 2, 3]) + Object.defineProperty(uint8, 'buffer', { + get () { + return new SharedArrayBuffer(8) + } + }) + + webidl.converters.TypedArray(uint8, Uint8Array, { + allowShared: false + }) + }, TypeError) + + t.end() +}) + +test('DataView', (t) => { + t.throws(() => { + webidl.converters.DataView(3) + }, TypeError) + + t.throws(() => { + webidl.converters.DataView({}) + }, TypeError) + + t.throws(() => { + const buffer = new ArrayBuffer(16) + const view = new DataView(buffer, 0) + + Object.defineProperty(view, 'buffer', { + get () { + return new SharedArrayBuffer(8) + } + }) + + webidl.converters.DataView(view, { + allowShared: false + }) + }) + + const buffer = new ArrayBuffer(16) + const view = new DataView(buffer, 0) + + t.equal(webidl.converters.DataView(view), view) + + t.end() +}) + +test('BufferSource', (t) => { + t.doesNotThrow(() => { + const buffer = new ArrayBuffer(16) + const view = new DataView(buffer, 0) + + webidl.converters.BufferSource(view) + }) + + t.throws(() => { + webidl.converters.BufferSource(3) + }, TypeError) + + t.end() +}) + +test('ByteString', (t) => { + t.doesNotThrow(() => { + webidl.converters.ByteString('') + }) + + // https://github.com/nodejs/undici/issues/1590 + t.throws(() => { + const char = String.fromCharCode(256) + webidl.converters.ByteString(`invalid${char}char`) + }, { + message: 'Cannot convert argument to a ByteString because the character at ' + + 'index 7 has a value of 256 which is greater than 255.' + }) + + t.end() +}) diff --git a/test/webidl/helpers.js b/test/webidl/helpers.js new file mode 100644 index 0000000..f44d501 --- /dev/null +++ b/test/webidl/helpers.js @@ -0,0 +1,75 @@ +'use strict' + +const { test } = require('tap') +const { webidl } = require('../../lib/fetch/webidl') + +test('webidl.interfaceConverter', (t) => { + class A {} + class B {} + + const converter = webidl.interfaceConverter(A) + + t.throws(() => { + converter(new B()) + }, TypeError) + + t.doesNotThrow(() => { + converter(new A()) + }) + + t.end() +}) + +test('webidl.dictionaryConverter', (t) => { + t.test('extraneous keys are provided', (t) => { + const converter = webidl.dictionaryConverter([ + { + key: 'key', + converter: webidl.converters.USVString, + defaultValue: 420, + required: true + } + ]) + + t.same( + converter({ + a: 'b', + key: 'string', + c: 'd', + get value () { + return 6 + } + }), + { key: 'string' } + ) + + t.end() + }) + + t.test('defaultValue with key = null', (t) => { + const converter = webidl.dictionaryConverter([ + { + key: 'key', + converter: webidl.converters['unsigned short'], + defaultValue: 200 + } + ]) + + t.same(converter({ key: null }), { key: 0 }) + t.end() + }) + + t.test('no defaultValue and optional', (t) => { + const converter = webidl.dictionaryConverter([ + { + key: 'key', + converter: webidl.converters.ByteString + } + ]) + + t.same(converter({ a: 'b', c: 'd' }), {}) + t.end() + }) + + t.end() +}) diff --git a/test/webidl/util.js b/test/webidl/util.js new file mode 100644 index 0000000..c451590 --- /dev/null +++ b/test/webidl/util.js @@ -0,0 +1,106 @@ +'use strict' + +const { test } = require('tap') +const { webidl } = require('../../lib/fetch/webidl') + +test('Type(V)', (t) => { + const Type = webidl.util.Type + + t.equal(Type(undefined), 'Undefined') + t.equal(Type(null), 'Null') + t.equal(Type(true), 'Boolean') + t.equal(Type('string'), 'String') + t.equal(Type(Symbol('symbol')), 'Symbol') + t.equal(Type(1.23), 'Number') + t.equal(Type(1n), 'BigInt') + t.equal(Type({ a: 'b' }), 'Object') + + t.end() +}) + +test('ConvertToInt(V)', (t) => { + const ConvertToInt = webidl.util.ConvertToInt + + t.equal(ConvertToInt(63, 64, 'signed'), 63, 'odd int') + t.equal(ConvertToInt(64.49, 64, 'signed'), 64) + t.equal(ConvertToInt(64.51, 64, 'signed'), 64) + + const max = 2 ** 53 + t.equal(ConvertToInt(max + 1, 64, 'signed'), max, 'signed pos') + t.equal(ConvertToInt(-max - 1, 64, 'signed'), -max, 'signed neg') + + t.equal(ConvertToInt(max + 1, 64, 'unsigned'), max + 1, 'unsigned pos') + t.equal(ConvertToInt(-max - 1, 64, 'unsigned'), -max - 1, 'unsigned neg') + + for (const signedness of ['signed', 'unsigned']) { + t.equal(ConvertToInt(Infinity, 64, signedness), 0) + t.equal(ConvertToInt(-Infinity, 64, signedness), 0) + t.equal(ConvertToInt(NaN, 64, signedness), 0) + } + + for (const signedness of ['signed', 'unsigned']) { + t.throws(() => { + ConvertToInt(NaN, 64, signedness, { + enforceRange: true + }) + }, TypeError) + + t.throws(() => { + ConvertToInt(Infinity, 64, signedness, { + enforceRange: true + }) + }, TypeError) + + t.throws(() => { + ConvertToInt(-Infinity, 64, signedness, { + enforceRange: true + }) + }, TypeError) + + t.throws(() => { + ConvertToInt(2 ** 53 + 1, 32, 'signed', { + enforceRange: true + }) + }, TypeError) + + t.throws(() => { + ConvertToInt(-(2 ** 53 + 1), 32, 'unsigned', { + enforceRange: true + }) + }, TypeError) + + t.equal( + ConvertToInt(65.5, 64, signedness, { + enforceRange: true + }), + 65 + ) + } + + for (const signedness of ['signed', 'unsigned']) { + t.equal( + ConvertToInt(63.49, 64, signedness, { + clamp: true + }), + 64 + ) + + t.equal( + ConvertToInt(63.51, 64, signedness, { + clamp: true + }), + 64 + ) + + t.equal( + ConvertToInt(-0, 64, signedness, { + clamp: true + }), + 0 + ) + } + + t.equal(ConvertToInt(111, 2, 'signed'), -1) + + t.end() +}) diff --git a/test/websocket/close.js b/test/websocket/close.js new file mode 100644 index 0000000..4d314a4 --- /dev/null +++ b/test/websocket/close.js @@ -0,0 +1,130 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') + +test('Close', (t) => { + t.plan(6) + + t.test('Close with code', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('close', (code) => { + t.equal(code, 1000) + }) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.addEventListener('open', () => ws.close(1000)) + }) + + t.test('Close with code and reason', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('close', (code, reason) => { + t.equal(code, 1000) + t.same(reason, Buffer.from('Goodbye')) + }) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.addEventListener('open', () => ws.close(1000, 'Goodbye')) + }) + + t.test('Close with invalid code', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.addEventListener('open', () => { + t.throws( + () => ws.close(2999), + { + name: 'InvalidAccessError', + constructor: DOMException + } + ) + + t.throws( + () => ws.close(5000), + { + name: 'InvalidAccessError', + constructor: DOMException + } + ) + + ws.close() + }) + }) + + t.test('Close with invalid reason', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + t.throws( + () => ws.close(1000, 'a'.repeat(124)), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + ws.close(1000) + }) + }) + + t.test('Close with no code or reason', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('close', (code, reason) => { + t.equal(code, 1005) + t.same(reason, Buffer.alloc(0)) + }) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.addEventListener('open', () => ws.close()) + }) + + t.test('Close with a 3000 status code', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('close', (code, reason) => { + t.equal(code, 3000) + t.same(reason, Buffer.alloc(0)) + }) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.addEventListener('open', () => ws.close(3000)) + }) +}) diff --git a/test/websocket/constructor.js b/test/websocket/constructor.js new file mode 100644 index 0000000..dd87dea --- /dev/null +++ b/test/websocket/constructor.js @@ -0,0 +1,48 @@ +'use strict' + +const { test } = require('tap') +const { WebSocket } = require('../..') + +test('Constructor', (t) => { + t.throws( + () => new WebSocket('abc'), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + t.throws( + () => new WebSocket('wss://echo.websocket.events/#a'), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + t.throws( + () => new WebSocket('wss://echo.websocket.events', ''), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + t.throws( + () => new WebSocket('wss://echo.websocket.events', ['chat', 'chat']), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + t.throws( + () => new WebSocket('wss://echo.websocket.events', ['<>@,;:\\"/[]?={}\t']), + { + name: 'SyntaxError', + constructor: DOMException + } + ) + + t.end() +}) diff --git a/test/websocket/custom-headers.js b/test/websocket/custom-headers.js new file mode 100644 index 0000000..01f1830 --- /dev/null +++ b/test/websocket/custom-headers.js @@ -0,0 +1,30 @@ +'use strict' + +const { test } = require('tap') +const assert = require('assert') +const { Agent, WebSocket } = require('../..') + +test('Setting custom headers', (t) => { + t.plan(1) + + const headers = { + 'x-khafra-hello': 'hi', + Authorization: 'Bearer base64orsomethingitreallydoesntmatter' + } + + class TestAgent extends Agent { + dispatch (options) { + t.match(options.headers, headers) + + return false + } + } + + const ws = new WebSocket('wss://echo.websocket.events', { + headers, + dispatcher: new TestAgent() + }) + + // We don't want to make a request, just ensure the headers are set. + ws.onclose = ws.onerror = ws.onmessage = assert.fail +}) diff --git a/test/websocket/diagnostics-channel.js b/test/websocket/diagnostics-channel.js new file mode 100644 index 0000000..c3bf05a --- /dev/null +++ b/test/websocket/diagnostics-channel.js @@ -0,0 +1,71 @@ +'use strict' + +const t = require('tap') +const dc = require('diagnostics_channel') +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') + +t.test('diagnostics channel', { jobs: 1 }, (t) => { + t.plan(2) + + t.test('undici:websocket:open', (t) => { + t.plan(3) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.close(1000, 'goodbye') + }) + + const listener = ({ extensions, protocol }) => { + t.equal(extensions, null) + t.equal(protocol, 'chat') + } + + t.teardown(() => { + dc.channel('undici:websocket:open').unsubscribe(listener) + return server.close() + }) + + const { port } = server.address() + + dc.channel('undici:websocket:open').subscribe(listener) + + const ws = new WebSocket(`ws://localhost:${port}`, 'chat') + + ws.addEventListener('open', () => { + t.pass('Emitted open') + }) + }) + + t.test('undici:websocket:close', (t) => { + t.plan(4) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.close(1000, 'goodbye') + }) + + const listener = ({ websocket, code, reason }) => { + t.type(websocket, WebSocket) + t.equal(code, 1000) + t.equal(reason, 'goodbye') + } + + t.teardown(() => { + dc.channel('undici:websocket:close').unsubscribe(listener) + return server.close() + }) + + const { port } = server.address() + + dc.channel('undici:websocket:close').subscribe(listener) + + const ws = new WebSocket(`ws://localhost:${port}`, 'chat') + + ws.addEventListener('close', () => { + t.pass('Emitted open') + }) + }) +}) diff --git a/test/websocket/events.js b/test/websocket/events.js new file mode 100644 index 0000000..e5b565c --- /dev/null +++ b/test/websocket/events.js @@ -0,0 +1,204 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { MessageEvent, CloseEvent, ErrorEvent } = require('../../lib/websocket/events') +const { WebSocket } = require('../..') + +test('MessageEvent', (t) => { + t.throws(() => new MessageEvent(), TypeError, 'no arguments') + t.throws(() => new MessageEvent('').initMessageEvent(), TypeError) + + const noInitEvent = new MessageEvent('message') + + t.equal(noInitEvent.origin, '') + t.equal(noInitEvent.data, null) + t.equal(noInitEvent.lastEventId, '') + t.equal(noInitEvent.source, null) + t.ok(Array.isArray(noInitEvent.ports)) + t.ok(Object.isFrozen(noInitEvent.ports)) + t.type(new MessageEvent('').initMessageEvent('message'), MessageEvent) + + t.end() +}) + +test('CloseEvent', (t) => { + t.throws(() => new CloseEvent(), TypeError) + + const noInitEvent = new CloseEvent('close') + + t.equal(noInitEvent.wasClean, false) + t.equal(noInitEvent.code, 0) + t.equal(noInitEvent.reason, '') + + t.end() +}) + +test('ErrorEvent', (t) => { + t.throws(() => new ErrorEvent(), TypeError) + + const noInitEvent = new ErrorEvent('error') + + t.equal(noInitEvent.message, '') + t.equal(noInitEvent.filename, '') + t.equal(noInitEvent.lineno, 0) + t.equal(noInitEvent.colno, 0) + t.equal(noInitEvent.error, undefined) + + t.end() +}) + +test('Event handlers', (t) => { + t.plan(4) + + const server = new WebSocketServer({ port: 0 }) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + function listen () {} + + t.teardown(server.close.bind(server)) + t.teardown(() => ws.close()) + + t.test('onopen', (t) => { + t.plan(3) + + t.equal(ws.onopen, null) + ws.onopen = 3 + t.equal(ws.onopen, null) + ws.onopen = listen + t.equal(ws.onopen, listen) + }) + + t.test('onerror', (t) => { + t.plan(3) + + t.equal(ws.onerror, null) + ws.onerror = 3 + t.equal(ws.onerror, null) + ws.onerror = listen + t.equal(ws.onerror, listen) + }) + + t.test('onclose', (t) => { + t.plan(3) + + t.equal(ws.onclose, null) + ws.onclose = 3 + t.equal(ws.onclose, null) + ws.onclose = listen + t.equal(ws.onclose, listen) + }) + + t.test('onmessage', (t) => { + t.plan(3) + + t.equal(ws.onmessage, null) + ws.onmessage = 3 + t.equal(ws.onmessage, null) + ws.onmessage = listen + t.equal(ws.onmessage, listen) + }) +}) + +test('CloseEvent WPTs ported', (t) => { + t.test('initCloseEvent', (t) => { + // Taken from websockets/interfaces/CloseEvent/historical.html + t.notOk('initCloseEvent' in CloseEvent.prototype) + t.notOk('initCloseEvent' in new CloseEvent('close')) + + t.end() + }) + + t.test('CloseEvent constructor', (t) => { + // Taken from websockets/interfaces/CloseEvent/constructor.html + + { + const event = new CloseEvent('foo') + + t.ok(event instanceof CloseEvent, 'should be a CloseEvent') + t.equal(event.type, 'foo') + t.notOk(event.bubbles, 'bubbles') + t.notOk(event.cancelable, 'cancelable') + t.notOk(event.wasClean, 'wasClean') + t.equal(event.code, 0) + t.equal(event.reason, '') + } + + { + const event = new CloseEvent('foo', { + bubbles: true, + cancelable: true, + wasClean: true, + code: 7, + reason: 'x' + }) + t.ok(event instanceof CloseEvent, 'should be a CloseEvent') + t.equal(event.type, 'foo') + t.ok(event.bubbles, 'bubbles') + t.ok(event.cancelable, 'cancelable') + t.ok(event.wasClean, 'wasClean') + t.equal(event.code, 7) + t.equal(event.reason, 'x') + } + + t.end() + }) + + t.end() +}) + +test('ErrorEvent WPTs ported', (t) => { + t.test('Synthetic ErrorEvent', (t) => { + // Taken from html/webappapis/scripting/events/event-handler-processing-algorithm-error/document-synthetic-errorevent.html + + { + const e = new ErrorEvent('error') + t.equal(e.message, '') + t.equal(e.filename, '') + t.equal(e.lineno, 0) + t.equal(e.colno, 0) + t.equal(e.error, undefined) + } + + { + const e = new ErrorEvent('error', { error: null }) + t.equal(e.error, null) + } + + { + const e = new ErrorEvent('error', { error: undefined }) + t.equal(e.error, undefined) + } + + { + const e = new ErrorEvent('error', { error: 'foo' }) + t.equal(e.error, 'foo') + } + + t.end() + }) + + t.test('webidl', (t) => { + // Taken from webidl/ecmascript-binding/no-regexp-special-casing.any.js + + const regExp = new RegExp() + regExp.message = 'some message' + + const errorEvent = new ErrorEvent('type', regExp) + + t.equal(errorEvent.message, 'some message') + + t.end() + }) + + t.test('initErrorEvent', (t) => { + // Taken from workers/Worker_dispatchEvent_ErrorEvent.htm + + const e = new ErrorEvent('error') + t.notOk('initErrorEvent' in e, 'should not be supported') + + t.end() + }) + + t.end() +}) diff --git a/test/websocket/fragments.js b/test/websocket/fragments.js new file mode 100644 index 0000000..d51db4b --- /dev/null +++ b/test/websocket/fragments.js @@ -0,0 +1,40 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') +const diagnosticsChannel = require('diagnostics_channel') + +test('Fragmented frame with a ping frame in the middle of it', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + const socket = ws._socket + + socket.write(Buffer.from([0x01, 0x03, 0x48, 0x65, 0x6c])) // Text frame "Hel" + socket.write(Buffer.from([0x89, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f])) // ping "Hello" + socket.write(Buffer.from([0x80, 0x02, 0x6c, 0x6f])) // Text frame "lo" + }) + + t.teardown(() => { + for (const client of server.clients) { + client.close() + } + + server.close() + }) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('message', ({ data }) => { + t.same(data, 'Hello') + + ws.close() + }) + + diagnosticsChannel.channel('undici:websocket:ping').subscribe( + ({ payload }) => t.same(payload, Buffer.from('Hello')) + ) +}) diff --git a/test/websocket/frame.js b/test/websocket/frame.js new file mode 100644 index 0000000..b4b73b7 --- /dev/null +++ b/test/websocket/frame.js @@ -0,0 +1,24 @@ +'use strict' + +const { test } = require('tap') +const { WebsocketFrameSend } = require('../../lib/websocket/frame') +const { opcodes } = require('../../lib/websocket/constants') + +test('Writing 16-bit frame length value at correct offset when buffer has a non-zero byteOffset', (t) => { + /* + When writing 16-bit frame lengths, a `DataView` was being used without setting a `byteOffset` into the buffer: + i.e. `new DataView(buffer.buffer)` instead of `new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)`. + Small `Buffers` returned by `allocUnsafe` are usually returned from the buffer pool, and thus have a non-zero `byteOffset`. + Invalid frames were therefore being returned in that case. + */ + t.plan(3) + + const payloadLength = 126 // 126 bytes is the smallest payload to trigger a 16-bit length field + const smallBuffer = Buffer.allocUnsafe(1) // make it very likely that the next buffer returned by allocUnsafe DOESN'T have a zero byteOffset + const payload = Buffer.allocUnsafe(payloadLength).fill(0) + const frame = new WebsocketFrameSend(payload).createFrame(opcodes.BINARY) + + t.equal(frame[2], payloadLength >>> 8) + t.equal(frame[3], payloadLength & 0xff) + t.equal(smallBuffer.length, 1) // ensure smallBuffer can't be garbage-collected too soon +}) diff --git a/test/websocket/opening-handshake.js b/test/websocket/opening-handshake.js new file mode 100644 index 0000000..b9a7989 --- /dev/null +++ b/test/websocket/opening-handshake.js @@ -0,0 +1,215 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') + +test('WebSocket connecting to server that isn\'t a Websocket server', (t) => { + t.plan(5) + + const server = createServer((req, res) => { + t.equal(req.headers.connection, 'upgrade') + t.equal(req.headers.upgrade, 'websocket') + t.ok(req.headers['sec-websocket-key']) + t.equal(req.headers['sec-websocket-version'], '13') + + res.end() + server.unref() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + // Server isn't a websocket server + ws.onmessage = ws.onopen = t.fail + + ws.addEventListener('error', t.pass) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Open event is emitted', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.close(1000) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.onmessage = ws.onerror = t.fail + ws.addEventListener('open', t.pass) +}) + +test('Multiple protocols are joined by a comma', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws, req) => { + t.equal(req.headers['sec-websocket-protocol'], 'chat, echo') + + ws.close(1000) + server.close() + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, ['chat', 'echo']) + + ws.addEventListener('open', () => ws.close()) +}) + +test('Server doesn\'t send Sec-WebSocket-Protocol header when protocols are used', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.statusCode = 101 + + req.socket.destroy() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`, 'chat') + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Server sends invalid Upgrade header', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('Upgrade', 'NotWebSocket') + res.statusCode = 101 + + req.socket.destroy() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Server sends invalid Connection header', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('Upgrade', 'websocket') + res.setHeader('Connection', 'downgrade') + res.statusCode = 101 + + req.socket.destroy() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Server sends invalid Sec-WebSocket-Accept header', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('Upgrade', 'websocket') + res.setHeader('Connection', 'upgrade') + res.setHeader('Sec-WebSocket-Accept', 'abc') + res.statusCode = 101 + + req.socket.destroy() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Server sends invalid Sec-WebSocket-Extensions header', (t) => { + const uid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + const { createHash } = require('crypto') + + t.plan(2) + + const server = createServer((req, res) => { + const key = req.headers['sec-websocket-key'] + t.ok(key) + + const accept = createHash('sha1').update(key + uid).digest('base64') + + res.setHeader('Upgrade', 'websocket') + res.setHeader('Connection', 'upgrade') + res.setHeader('Sec-WebSocket-Accept', accept) + res.setHeader('Sec-WebSocket-Extensions', 'InvalidExtension') + res.statusCode = 101 + + res.end() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) + +test('Server sends invalid Sec-WebSocket-Extensions header', (t) => { + const uid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + const { createHash } = require('crypto') + + t.plan(2) + + const server = createServer((req, res) => { + const key = req.headers['sec-websocket-key'] + t.ok(key) + + const accept = createHash('sha1').update(key + uid).digest('base64') + + res.setHeader('Upgrade', 'websocket') + res.setHeader('Connection', 'upgrade') + res.setHeader('Sec-WebSocket-Accept', accept) + res.setHeader('Sec-WebSocket-Protocol', 'echo') // <-- + res.statusCode = 101 + + res.end() + }).listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`, 'chat') + + ws.onopen = t.fail + + ws.addEventListener('error', ({ error }) => { + t.ok(error) + }) + }) + + t.teardown(server.close.bind(server)) +}) diff --git a/test/websocket/ping-pong.js b/test/websocket/ping-pong.js new file mode 100644 index 0000000..b7c4694 --- /dev/null +++ b/test/websocket/ping-pong.js @@ -0,0 +1,46 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const diagnosticsChannel = require('diagnostics_channel') +const { WebSocket } = require('../..') + +test('Receives ping and parses body', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.ping('Hello, world') + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.onerror = ws.onmessage = t.fail + + diagnosticsChannel.channel('undici:websocket:ping').subscribe(({ payload }) => { + t.same(payload, Buffer.from('Hello, world')) + ws.close() + }) +}) + +test('Receives pong and parses body', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.pong('Pong') + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + ws.onerror = ws.onmessage = t.fail + + diagnosticsChannel.channel('undici:websocket:pong').subscribe(({ payload }) => { + t.same(payload, Buffer.from('Pong')) + ws.close() + }) +}) diff --git a/test/websocket/receive.js b/test/websocket/receive.js new file mode 100644 index 0000000..a669022 --- /dev/null +++ b/test/websocket/receive.js @@ -0,0 +1,60 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') + +test('Receiving a frame with a payload length > 2^31-1 bytes', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + const socket = ws._socket + + socket.write(Buffer.from([0x81, 0x7F, 0xCA, 0xE5, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00])) + }) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + t.teardown(() => { + ws.close() + server.close() + }) + + ws.onmessage = t.fail + + ws.addEventListener('error', (event) => { + t.type(event.error, Error) // error event is emitted + }) +}) + +test('Receiving an ArrayBuffer', (t) => { + t.plan(3) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: true }) + + ws.close(1000) + }) + }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.binaryType = 'what' + t.equal(ws.binaryType, 'blob') + + ws.binaryType = 'arraybuffer' // <-- + ws.send('Hello') + }) + + ws.addEventListener('message', ({ data }) => { + t.type(data, ArrayBuffer) + t.same(Buffer.from(data), Buffer.from('Hello')) + }) +}) diff --git a/test/websocket/send.js b/test/websocket/send.js new file mode 100644 index 0000000..ac295fd --- /dev/null +++ b/test/websocket/send.js @@ -0,0 +1,216 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { Blob } = require('buffer') +const { WebSocket } = require('../..') + +// the following three tests exercise different code paths because of the three +// different ways a payload length may be specified in a WebSocket frame +// (https://datatracker.ietf.org/doc/html/rfc6455#section-5.2) + +test('Sending >= 2^16 bytes', (t) => { + t.plan(3) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (m, isBinary) => { + ws.send(m, { binary: isBinary }) + }) + }) + + const payload = Buffer.allocUnsafe(2 ** 16).fill('Hello') + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send(payload) + }) + + ws.addEventListener('message', async ({ data }) => { + t.type(data, Blob) + t.equal(data.size, payload.length) + t.same(Buffer.from(await data.arrayBuffer()), payload) + + ws.close() + server.close() + }) +}) + +test('Sending >= 126, < 2^16 bytes', (t) => { + t.plan(3) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (m, isBinary) => { + ws.send(m, { binary: isBinary }) + }) + }) + + const payload = Buffer.allocUnsafe(126).fill('Hello') + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send(payload) + }) + + ws.addEventListener('message', async ({ data }) => { + t.type(data, Blob) + t.equal(data.size, payload.length) + t.same(Buffer.from(await data.arrayBuffer()), payload) + + ws.close() + server.close() + }) +}) + +test('Sending < 126 bytes', (t) => { + t.plan(3) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (m, isBinary) => { + ws.send(m, { binary: isBinary }) + }) + }) + + const payload = Buffer.allocUnsafe(125).fill('Hello') + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send(payload) + }) + + ws.addEventListener('message', async ({ data }) => { + t.type(data, Blob) + t.equal(data.size, payload.length) + t.same(Buffer.from(await data.arrayBuffer()), payload) + + ws.close() + server.close() + }) +}) + +test('Sending data after close', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + t.pass() + + ws.on('message', t.fail) + }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.close() + ws.send('Some message') + + t.pass() + }) + + ws.addEventListener('error', t.fail) +}) + +test('Sending data before connected', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + t.throws( + () => ws.send('Not sent'), + { + name: 'InvalidStateError', + constructor: DOMException + } + ) + + t.equal(ws.readyState, WebSocket.CONNECTING) +}) + +test('Sending data to a server', (t) => { + t.plan(3) + + t.test('Send with string', (t) => { + t.plan(2) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + t.notOk(isBinary, 'Received text frame') + t.same(data, Buffer.from('message')) + + ws.close(1000) + }) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send('message') + }) + }) + + t.test('Send with ArrayBuffer', (t) => { + t.plan(2) + + const message = new TextEncoder().encode('message') + const ab = new ArrayBuffer(7) + new Uint8Array(ab).set(message) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + t.ok(isBinary) + t.same(new Uint8Array(data), message) + + ws.close(1000) + }) + }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send(ab) + }) + }) + + t.test('Send with Blob', (t) => { + t.plan(2) + + const blob = new Blob(['hello']) + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + t.ok(isBinary) + t.same(data, Buffer.from('hello')) + + ws.close(1000) + }) + }) + + t.teardown(server.close.bind(server)) + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + ws.addEventListener('open', () => { + ws.send(blob) + }) + }) +}) diff --git a/test/websocket/websocketinit.js b/test/websocket/websocketinit.js new file mode 100644 index 0000000..4dda3b4 --- /dev/null +++ b/test/websocket/websocketinit.js @@ -0,0 +1,45 @@ +'use strict' + +const { test } = require('tap') +const { WebSocketServer } = require('ws') +const { WebSocket, Dispatcher, Agent } = require('../..') + +test('WebSocketInit', (t) => { + t.plan(2) + + class WsDispatcher extends Dispatcher { + constructor () { + super() + this.agent = new Agent() + } + + dispatch () { + t.pass() + return this.agent.dispatch(...arguments) + } + } + + t.test('WebSocketInit as 2nd param', (t) => { + t.plan(1) + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.send(Buffer.from('hello, world')) + }) + + t.teardown(server.close.bind(server)) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + dispatcher: new WsDispatcher() + }) + + ws.onerror = t.fail + + ws.addEventListener('message', async (event) => { + t.equal(await event.data.text(), 'hello, world') + server.close() + ws.close() + }) + }) +}) diff --git a/test/wpt/runner/runner.mjs b/test/wpt/runner/runner.mjs new file mode 100644 index 0000000..5bec326 --- /dev/null +++ b/test/wpt/runner/runner.mjs @@ -0,0 +1,356 @@ +import { EventEmitter, once } from 'node:events' +import { isAbsolute, join, resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { colors, handlePipes, normalizeName, parseMeta, resolveStatusPath } from './util.mjs' + +const basePath = fileURLToPath(join(import.meta.url, '../..')) +const testPath = join(basePath, 'tests') +const statusPath = join(basePath, 'status') + +// https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L3705 +function sanitizeUnpairedSurrogates (str) { + return str.replace( + /([\ud800-\udbff]+)(?![\udc00-\udfff])|(^|[^\ud800-\udbff])([\udc00-\udfff]+)/g, + function (_, low, prefix, high) { + let output = prefix || '' // Prefix may be undefined + const string = low || high // Only one of these alternates can match + for (let i = 0; i < string.length; i++) { + output += codeUnitStr(string[i]) + } + return output + }) +} + +function codeUnitStr (char) { + return 'U+' + char.charCodeAt(0).toString(16) +} + +export class WPTRunner extends EventEmitter { + /** @type {string} */ + #folderName + + /** @type {string} */ + #folderPath + + /** @type {string[]} */ + #files = [] + + /** @type {string[]} */ + #initScripts = [] + + /** @type {string} */ + #url + + /** @type {import('../../status/fetch.status.json')} */ + #status + + /** Tests that have expectedly failed mapped by file name */ + #statusOutput = {} + + #uncaughtExceptions = [] + + /** @type {boolean} */ + #appendReport + + /** @type {string} */ + #reportPath + + #stats = { + completed: 0, + failed: 0, + success: 0, + expectedFailures: 0, + skipped: 0 + } + + constructor (folder, url, { appendReport = false, reportPath } = {}) { + super() + + this.#folderName = folder + this.#folderPath = join(testPath, folder) + this.#files.push( + ...WPTRunner.walk( + this.#folderPath, + (file) => file.endsWith('.any.js') + ) + ) + + if (appendReport) { + if (!reportPath) { + throw new TypeError('reportPath must be provided when appendReport is true') + } + if (!existsSync(reportPath)) { + throw new TypeError('reportPath is invalid') + } + } + + this.#appendReport = appendReport + this.#reportPath = reportPath + + this.#status = JSON.parse(readFileSync(join(statusPath, `${folder}.status.json`))) + this.#url = url + + if (this.#files.length === 0) { + queueMicrotask(() => { + this.emit('completion') + }) + } + + this.once('completion', () => { + for (const { error, test } of this.#uncaughtExceptions) { + console.log(colors(`Uncaught exception in "${test}":`, 'red')) + console.log(colors(`${error.stack}`, 'red')) + console.log('='.repeat(96)) + } + }) + } + + static walk (dir, fn) { + const ini = new Set(readdirSync(dir)) + const files = new Set() + + while (ini.size !== 0) { + for (const d of ini) { + const path = resolve(dir, d) + ini.delete(d) // remove from set + const stats = statSync(path) + + if (stats.isDirectory()) { + for (const f of readdirSync(path)) { + ini.add(resolve(path, f)) + } + } else if (stats.isFile() && fn(d)) { + files.add(path) + } + } + } + + return [...files].sort() + } + + async run () { + const workerPath = fileURLToPath(join(import.meta.url, '../worker.mjs')) + /** @type {Set} */ + const activeWorkers = new Set() + let finishedFiles = 1 + let total = this.#files.length + + const files = this.#files.map((test) => { + const code = test.includes('.sub.') + ? handlePipes(readFileSync(test, 'utf-8'), this.#url) + : readFileSync(test, 'utf-8') + const meta = this.resolveMeta(code, test) + + if (meta.variant.length) { + total += meta.variant.length - 1 + } + + return [test, code, meta] + }) + + console.log('='.repeat(96)) + + for (const [test, code, meta] of files) { + console.log(`Started ${test}`) + + const status = resolveStatusPath(test, this.#status) + + if (status.file.skip || status.topLevel.skip) { + this.#stats.skipped += 1 + + console.log(colors(`[${finishedFiles}/${total}] SKIPPED - ${test}`, 'yellow')) + console.log('='.repeat(96)) + + finishedFiles++ + continue + } + + const start = performance.now() + + for (const variant of meta.variant.length ? meta.variant : ['']) { + const url = new URL(this.#url) + if (variant) { + url.search = variant + } + const worker = new Worker(workerPath, { + workerData: { + // Code to load before the test harness and tests. + initScripts: this.#initScripts, + // The test file. + test: code, + // Parsed META tag information + meta, + url: url.href, + path: test + } + }) + + let result, report + if (this.#appendReport) { + report = JSON.parse(readFileSync(this.#reportPath)) + + const fileUrl = new URL(`/${this.#folderName}${test.slice(this.#folderPath.length)}`, 'http://wpt') + fileUrl.pathname = fileUrl.pathname.replace(/\.js$/, '.html') + fileUrl.search = variant + + result = { + test: fileUrl.href.slice(fileUrl.origin.length), + subtests: [], + status: 'OK' + } + report.results.push(result) + } + + activeWorkers.add(worker) + // These values come directly from the web-platform-tests + const timeout = meta.timeout === 'long' ? 60_000 : 10_000 + + worker.on('message', (message) => { + if (message.type === 'result') { + this.handleIndividualTestCompletion(message, status, test, meta, result) + } else if (message.type === 'completion') { + this.handleTestCompletion(worker) + } else if (message.type === 'error') { + this.#uncaughtExceptions.push({ error: message.error, test }) + this.#stats.failed += 1 + this.#stats.success -= 1 + } + }) + + try { + await once(worker, 'exit', { + signal: AbortSignal.timeout(timeout) + }) + + console.log(colors(`[${finishedFiles}/${total}] PASSED - ${test}`, 'green')) + if (variant) console.log('Variant:', variant) + console.log(`Test took ${(performance.now() - start).toFixed(2)}ms`) + console.log('='.repeat(96)) + } catch (e) { + console.log(`${test} timed out after ${timeout}ms`) + } finally { + if (result?.subtests.length > 0) { + writeFileSync(this.#reportPath, JSON.stringify(report)) + } + + finishedFiles++ + activeWorkers.delete(worker) + } + } + } + + this.handleRunnerCompletion() + } + + /** + * Called after a test has succeeded or failed. + */ + handleIndividualTestCompletion (message, status, path, meta, wptResult) { + const { file, topLevel } = status + + if (message.type === 'result') { + this.#stats.completed += 1 + + if (message.result.status === 1) { + this.#stats.failed += 1 + + wptResult?.subtests.push({ + status: 'FAIL', + name: sanitizeUnpairedSurrogates(message.result.name), + message: sanitizeUnpairedSurrogates(message.result.message) + }) + + const name = normalizeName(message.result.name) + + if (file.flaky?.includes(name)) { + this.#stats.expectedFailures += 1 + } else if (file.allowUnexpectedFailures || topLevel.allowUnexpectedFailures || file.fail?.includes(name)) { + if (!file.allowUnexpectedFailures && !topLevel.allowUnexpectedFailures) { + if (Array.isArray(file.fail)) { + this.#statusOutput[path] ??= [] + this.#statusOutput[path].push(name) + } + } + + this.#stats.expectedFailures += 1 + } else { + process.exitCode = 1 + console.error(message.result) + } + } else { + wptResult?.subtests.push({ + status: 'PASS', + name: sanitizeUnpairedSurrogates(message.result.name) + }) + this.#stats.success += 1 + } + } + } + + /** + * Called after all the tests in a worker are completed. + * @param {Worker} worker + */ + handleTestCompletion (worker) { + worker.terminate() + } + + /** + * Called after every test has completed. + */ + handleRunnerCompletion () { + console.log(this.#statusOutput) // tests that failed + + this.emit('completion') + const { completed, failed, success, expectedFailures, skipped } = this.#stats + console.log( + `[${this.#folderName}]: ` + + `Completed: ${completed}, failed: ${failed}, success: ${success}, ` + + `expected failures: ${expectedFailures}, ` + + `unexpected failures: ${failed - expectedFailures}, ` + + `skipped: ${skipped}` + ) + + process.exit(0) + } + + addInitScript (code) { + this.#initScripts.push(code) + } + + /** + * Parses META tags and resolves any script file paths. + * @param {string} code + * @param {string} path The absolute path of the test + */ + resolveMeta (code, path) { + const meta = parseMeta(code) + const scripts = meta.scripts.map((filePath) => { + let content = '' + + if (filePath === '/resources/WebIDLParser.js') { + // See https://github.com/web-platform-tests/wpt/pull/731 + return readFileSync(join(testPath, '/resources/webidl2/lib/webidl2.js'), 'utf-8') + } else if (isAbsolute(filePath)) { + content = readFileSync(join(testPath, filePath), 'utf-8') + } else { + content = readFileSync(resolve(path, '..', filePath), 'utf-8') + } + + // If the file has any built-in pipes. + if (filePath.includes('.sub.')) { + content = handlePipes(content, this.#url) + } + + return content + }) + + return { + ...meta, + resourcePaths: meta.scripts, + scripts + } + } +} diff --git a/test/wpt/runner/util.mjs b/test/wpt/runner/util.mjs new file mode 100644 index 0000000..ec284df --- /dev/null +++ b/test/wpt/runner/util.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert' +import { exit } from 'node:process' +import { inspect } from 'node:util' +import tty from 'node:tty' +import { sep } from 'node:path' + +/** + * Parse the `Meta:` tags sometimes included in tests. + * These can include resources to inject, how long it should + * take to timeout, and which globals to expose. + * @example + * // META: timeout=long + * // META: global=window,worker + * // META: script=/common/utils.js + * // META: script=/common/get-host-info.sub.js + * // META: script=../request/request-error.js + * @see https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line + * @param {string} fileContents + */ +export function parseMeta (fileContents) { + const lines = fileContents.split(/\r?\n/g) + + const meta = { + /** @type {string|null} */ + timeout: null, + /** @type {string[]} */ + global: [], + /** @type {string[]} */ + scripts: [], + /** @type {string[]} */ + variant: [] + } + + for (const line of lines) { + if (!line.startsWith('// META: ')) { + break + } + + const groups = /^\/\/ META: (?.*?)=(?.*)$/.exec(line)?.groups + + if (!groups) { + console.log(`Failed to parse META tag: ${line}`) + exit(1) + } + + switch (groups.type) { + case 'variant': + meta[groups.type].push(groups.match) + break + case 'title': + case 'timeout': { + meta[groups.type] = groups.match + break + } + case 'global': { + // window,worker -> ['window', 'worker'] + meta.global.push(...groups.match.split(',')) + break + } + case 'script': { + // A relative or absolute file path to the resources + // needed for the current test. + meta.scripts.push(groups.match) + break + } + default: { + console.log(`Unknown META tag: ${groups.type}`) + exit(1) + } + } + } + + return meta +} + +/** + * @param {string} sub + */ +function parseSubBlock (sub) { + const subName = sub.includes('[') ? sub.slice(0, sub.indexOf('[')) : sub + const options = sub.matchAll(/\[(.*?)\]/gm) + + return { + sub: subName, + options: [...options].map(match => match[1]) + } +} + +/** + * @see https://web-platform-tests.org/writing-tests/server-pipes.html?highlight=sub#built-in-pipes + * @param {string} code + * @param {string} url + */ +export function handlePipes (code, url) { + const server = new URL(url) + + // "Substitutions are marked in a file using a block delimited by + // {{ and }}. Inside the block the following variables are available:" + return code.replace(/{{(.*?)}}/gm, (_, match) => { + const { sub } = parseSubBlock(match) + + switch (sub) { + // "The host name of the server excluding any subdomain part." + // eslint-disable-next-line no-fallthrough + case 'host': + // "The domain name of a particular subdomain e.g. + // {{domains[www]}} for the www subdomain." + // eslint-disable-next-line no-fallthrough + case 'domains': + // "The domain name of a particular subdomain for a particular host. + // The first key may be empty (designating the “default” host) or + // the value alt; i.e., {{hosts[alt][]}} (designating the alternate + // host)." + // eslint-disable-next-line no-fallthrough + case 'hosts': { + return 'localhost' + } + // "The port number of servers, by protocol e.g. {{ports[http][0]}} + // for the first (and, depending on setup, possibly only) http server" + case 'ports': { + return server.port + } + default: { + throw new TypeError(`Unknown substitute "${sub}".`) + } + } + }) +} + +/** + * Some test names may contain characters that JSON cannot handle. + * @param {string} name + */ +export function normalizeName (name) { + return name.replace(/(\v)/g, (_, match) => { + switch (inspect(match)) { + case '\'\\x0B\'': return '\\x0B' + default: return match + } + }) +} + +export function colors (str, color) { + assert(Object.hasOwn(inspect.colors, color), `Missing color ${color}`) + + if (!tty.WriteStream.prototype.hasColors()) { + return str + } + + const [start, end] = inspect.colors[color] + + return `\u001b[${start}m${str}\u001b[${end}m` +} + +/** @param {string} path */ +export function resolveStatusPath (path, status) { + const paths = path + .slice(process.cwd().length + sep.length) + .split(sep) + .slice(3) // [test, wpt, tests, fetch, b, c.js] -> [fetch, b, c.js] + + // skip the first folder name + for (let i = 1; i < paths.length - 1; i++) { + status = status[paths[i]] + + if (!status) { + break + } + } + + return { topLevel: status ?? {}, file: status?.[paths.at(-1)] ?? {} } +} diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs new file mode 100644 index 0000000..90bfcf6 --- /dev/null +++ b/test/wpt/runner/worker.mjs @@ -0,0 +1,164 @@ +import buffer from 'node:buffer' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { setFlagsFromString } from 'node:v8' +import { runInNewContext, runInThisContext } from 'node:vm' +import { parentPort, workerData } from 'node:worker_threads' +import { + fetch, File, FileReader, FormData, Headers, Request, Response, setGlobalOrigin +} from '../../../index.js' +import { CloseEvent } from '../../../lib/websocket/events.js' +import { WebSocket } from '../../../lib/websocket/websocket.js' +import { Cache } from '../../../lib/cache/cache.js' +import { CacheStorage } from '../../../lib/cache/cachestorage.js' +import { kConstruct } from '../../../lib/cache/symbols.js' + +const { initScripts, meta, test, url, path } = workerData + +process.on('uncaughtException', (err) => { + parentPort.postMessage({ + type: 'error', + error: { + message: err.message, + name: err.name, + stack: err.stack + } + }) +}) + +const basePath = join(process.cwd(), 'test/wpt/tests') +const urlPath = path.slice(basePath.length) + +const globalPropertyDescriptors = { + writable: true, + enumerable: false, + configurable: true +} + +Object.defineProperties(globalThis, { + fetch: { + ...globalPropertyDescriptors, + enumerable: true, + value: fetch + }, + File: { + ...globalPropertyDescriptors, + value: buffer.File ?? File + }, + FormData: { + ...globalPropertyDescriptors, + value: FormData + }, + Headers: { + ...globalPropertyDescriptors, + value: Headers + }, + Request: { + ...globalPropertyDescriptors, + value: Request + }, + Response: { + ...globalPropertyDescriptors, + value: Response + }, + FileReader: { + ...globalPropertyDescriptors, + value: FileReader + }, + WebSocket: { + ...globalPropertyDescriptors, + value: WebSocket + }, + CloseEvent: { + ...globalPropertyDescriptors, + value: CloseEvent + }, + Blob: { + ...globalPropertyDescriptors, + // See https://github.com/nodejs/node/pull/45659 + value: buffer.Blob + }, + caches: { + ...globalPropertyDescriptors, + value: new CacheStorage(kConstruct) + }, + Cache: { + ...globalPropertyDescriptors, + value: Cache + }, + CacheStorage: { + ...globalPropertyDescriptors, + value: CacheStorage + } +}) + +// self is required by testharness +// GLOBAL is required by self +runInThisContext(` + globalThis.self = globalThis + globalThis.GLOBAL = { + isWorker () { + return false + }, + isShadowRealm () { + return false + }, + isWindow () { + return false + } + } + globalThis.window = globalThis + globalThis.location = new URL('${urlPath.replace(/\\/g, '/')}', '${url}') + globalThis.Window = Object.getPrototypeOf(globalThis).constructor +`) + +if (meta.title) { + runInThisContext(`globalThis.META_TITLE = "${meta.title}"`) +} + +const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8') +runInThisContext(harness) + +// add_*_callback comes from testharness +// stolen from node's wpt test runner +// eslint-disable-next-line no-undef +add_result_callback((result) => { + parentPort.postMessage({ + type: 'result', + result: { + status: result.status, + name: result.name, + message: result.message, + stack: result.stack + } + }) +}) + +// eslint-disable-next-line no-undef +add_completion_callback((_, status) => { + parentPort.postMessage({ + type: 'completion', + status + }) +}) + +setGlobalOrigin(globalThis.location) + +// Inject any script the user provided before +// running the tests. +for (const initScript of initScripts) { + runInThisContext(initScript) +} + +// Inject any files from the META tags +for (const script of meta.scripts) { + runInThisContext(script) +} + +// A few tests require gc, which can't be passed to a Worker. +// see https://github.com/nodejs/node/issues/16595#issuecomment-340288680 +setFlagsFromString('--expose-gc') +globalThis.gc = runInNewContext('gc') + +// Finally, run the test. +runInThisContext(test) diff --git a/test/wpt/server/routes/network-partition-key.mjs b/test/wpt/server/routes/network-partition-key.mjs new file mode 100644 index 0000000..f1203f7 --- /dev/null +++ b/test/wpt/server/routes/network-partition-key.mjs @@ -0,0 +1,111 @@ +const stash = new Map() + +/** + * @see https://github.com/web-platform-tests/wpt/blob/master/fetch/connection-pool/resources/network-partition-key.py + * @param {Parameters[0]} req + * @param {Parameters[1]} res + * @param {URL} url + */ +export function route (req, res, { searchParams, port }) { + res.setHeader('Cache-Control', 'no-store') + + const dispatch = searchParams.get('dispatch') + const uuid = searchParams.get('uuid') + const partitionId = searchParams.get('partition_id') + + if (!uuid || !dispatch || !partitionId) { + res.statusCode = 404 + res.end('Invalid query parameters') + return + } + + let testFailed = false + let requestCount = 0 + let connectionCount = 0 + + if (searchParams.get('nocheck_partition') !== 'True') { + const addressKey = `${req.socket.localAddress}|${port}` + const serverState = stash.get(uuid) ?? { + testFailed: false, + requestCount: 0, + connectionCount: 0 + } + + stash.delete(uuid) + requestCount = serverState.requestCount + 1 + serverState.requestCount = requestCount + + if (Object.hasOwn(serverState, addressKey)) { + if (serverState[addressKey] !== partitionId) { + serverState.testFailed = true + } + } else { + connectionCount = serverState.connectionCount + 1 + serverState.connectionCount = connectionCount + } + + serverState[addressKey] = partitionId + testFailed = serverState.testFailed + stash.set(uuid, serverState) + } + + const origin = req.headers.origin + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + } + + if (req.method === 'OPTIONS') { + return handlePreflight(req, res) + } + + if (dispatch === 'fetch_file') { + res.end() + return + } + + if (dispatch === 'check_partition') { + const status = searchParams.get('status') ?? 200 + + if (testFailed) { + res.statusCode = status + res.end('Multiple partition IDs used on a socket') + return + } + + let body = 'ok' + if (searchParams.get('addcounter')) { + body += `. Request was sent ${requestCount} times. ${connectionCount} connections were created.` + res.statusCode = status + res.end(body) + return + } + } + + if (dispatch === 'clean_up') { + stash.delete(uuid) + res.statusCode = 200 + if (testFailed) { + res.end('Test failed, but cleanup completed.') + } else { + res.end('cleanup complete') + } + + return + } + + res.statusCode = 404 + res.end('Unrecognized dispatch parameter: ' + dispatch) +} + +/** + * @param {Parameters[0]} req + * @param {Parameters[1]} res + */ +function handlePreflight (req, res) { + res.statusCode = 200 + res.setHeader('Access-Control-Allow-Methods', 'GET') + res.setHeader('Access-Control-Allow-Headers', 'header-to-force-cors') + res.setHeader('Access-Control-Max-Age', '86400') + res.end('Preflight request') +} diff --git a/test/wpt/server/routes/redirect.mjs b/test/wpt/server/routes/redirect.mjs new file mode 100644 index 0000000..46770cf --- /dev/null +++ b/test/wpt/server/routes/redirect.mjs @@ -0,0 +1,104 @@ +import { setTimeout } from 'timers/promises' + +const stash = new Map() + +/** + * @see https://github.com/web-platform-tests/wpt/blob/master/fetch/connection-pool/resources/network-partition-key.py + * @param {Parameters[0]} req + * @param {Parameters[1]} res + * @param {URL} fullUrl + */ +export async function route (req, res, fullUrl) { + const { searchParams } = fullUrl + + let stashedData = { count: 0, preflight: 0 } + let status = 302 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Pragma', 'no-cache') + + if (Object.hasOwn(req.headers, 'origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '') + res.setHeader('Access-Control-Allow-Credentials', 'true') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + let token = null + if (searchParams.has('token')) { + token = searchParams.get('token') + const data = stash.get(token) + stash.delete(token) + if (data) { + stashedData = data + } + } + + if (req.method === 'OPTIONS') { + if (searchParams.has('allow_headers')) { + res.setHeader('Access-Control-Allow-Headers', searchParams.get('allow_headers')) + } + + stashedData.preflight = '1' + + if (!searchParams.has('redirect_preflight')) { + if (token) { + stash.set(searchParams.get('token'), stashedData) + } + + res.statusCode = 200 + res.end('') + return + } + } + + if (searchParams.has('redirect_status')) { + status = parseInt(searchParams.get('redirect_status')) + } + + stashedData.count += 1 + + if (searchParams.has('location')) { + let url = decodeURIComponent(searchParams.get('location')) + + if (!searchParams.has('simple')) { + const scheme = new URL(url, fullUrl).protocol + + if (scheme === 'http:' || scheme === 'https:') { + url += url.includes('?') ? '&' : '?' + + for (const [key, value] of searchParams) { + url += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(value) + } + + url += '&count=' + stashedData.count + } + } + + res.setHeader('location', url) + } + + if (searchParams.has('redirect_referrerpolicy')) { + res.setHeader('Referrer-Policy', searchParams.get('redirect_referrerpolicy')) + } + + if (searchParams.has('delay')) { + await setTimeout(parseFloat(searchParams.get('delay') ?? 0)) + } + + if (token) { + stash.set(searchParams.get('token'), stashedData) + + if (searchParams.has('max_count')) { + const maxCount = parseInt(searchParams.get('max_count')) + + if (stashedData.count > maxCount) { + res.end((stashedData.count - 1).toString()) + return + } + } + } + + res.statusCode = status + res.end('') +} diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs new file mode 100644 index 0000000..82b9080 --- /dev/null +++ b/test/wpt/server/server.mjs @@ -0,0 +1,397 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import { join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { createReadStream, readFileSync, existsSync } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' +import { route as networkPartitionRoute } from './routes/network-partition-key.mjs' +import { route as redirectRoute } from './routes/redirect.mjs' + +const tests = fileURLToPath(join(import.meta.url, '../../tests')) + +// https://web-platform-tests.org/tools/wptserve/docs/stash.html +class Stash extends Map { + take (key) { + if (this.has(key)) { + const value = this.get(key) + + this.delete(key) + return value.value + } + } + + put (key, value, path) { + this.set(key, { value, path }) + } +} + +const stash = new Stash() + +const server = createServer(async (req, res) => { + const fullUrl = new URL(req.url, `http://localhost:${server.address().port}`) + + switch (fullUrl.pathname) { + case '/service-workers/cache-storage/resources/blank.html': { + res.setHeader('content-type', 'text/html') + // fall through + } + case '/service-workers/cache-storage/resources/simple.txt': + case '/fetch/content-encoding/resources/foo.octetstream.gz': + case '/fetch/content-encoding/resources/foo.text.gz': + case '/fetch/api/resources/cors-top.txt': + case '/fetch/api/resources/top.txt': + case '/mimesniff/mime-types/resources/generated-mime-types.json': + case '/mimesniff/mime-types/resources/mime-types.json': + case '/interfaces/dom.idl': + case '/interfaces/url.idl': + case '/interfaces/html.idl': + case '/interfaces/fetch.idl': + case '/interfaces/FileAPI.idl': + case '/interfaces/websockets.idl': + case '/interfaces/referrer-policy.idl': + case '/xhr/resources/utf16-bom.json': + case '/fetch/data-urls/resources/base64.json': + case '/fetch/data-urls/resources/data-urls.json': + case '/fetch/api/resources/empty.txt': + case '/fetch/api/resources/data.json': { + // If this specific resources requires custom headers + const customHeadersPath = join(tests, fullUrl.pathname + '.headers') + if (existsSync(customHeadersPath)) { + const headers = readFileSync(customHeadersPath, 'utf-8') + .trim() + .split(/\r?\n/g) + .map((h) => h.split(': ')) + + for (const [key, value] of headers) { + if (!key || !value) { + console.warn(`Skipping ${key}:${value} header pair`) + continue + } + res.setHeader(key, value) + } + } + + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/data.json + return createReadStream(join(tests, fullUrl.pathname)) + .on('end', () => res.end()) + .pipe(res) + } + case '/fetch/api/resources/trickle.py': { + // Note: python's time.sleep(...) takes seconds, while setTimeout + // takes ms. + const delay = parseFloat(fullUrl.searchParams.get('ms') ?? 500) + const count = parseInt(fullUrl.searchParams.get('count') ?? 50) + + // eslint-disable-next-line no-unused-vars + for await (const chunk of req); // read request body + + await sleep(delay) + + if (!fullUrl.searchParams.has('notype')) { + res.setHeader('Content-type', 'text/plain') + } + + res.statusCode = 200 + await sleep(delay) + + for (let i = 0; i < count; i++) { + res.write('TEST_TRICKLE\n') + await sleep(delay) + } + + res.end() + break + } + case '/fetch/api/resources/infinite-slow-response.py': { + // https://github.com/web-platform-tests/wpt/blob/master/fetch/api/resources/infinite-slow-response.py + const stateKey = fullUrl.searchParams.get('stateKey') ?? '' + const abortKey = fullUrl.searchParams.get('abortKey') ?? '' + + if (stateKey) { + stash.put(stateKey, 'open', fullUrl.pathname) + } + + res.setHeader('Content-Type', 'text/plain') + res.statusCode = 200 + + res.write('.'.repeat(2048)) + + while (true) { + if (!res.write('.')) { + break + } else if (abortKey && stash.take(abortKey)) { + break + } + + await sleep(100) + } + + if (stateKey) { + stash.put(stateKey, 'closed', fullUrl.pathname) + } + + res.end() + return + } + case '/fetch/api/resources/stash-take.py': { + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/stash-take.py + + const key = fullUrl.searchParams.get('key') + res.setHeader('Access-Control-Allow-Origin', '*') + + const took = stash.take(key, fullUrl.pathname) ?? null + + res.write(JSON.stringify(took)) + return res.end() + } + case '/fetch/api/resources/echo-content.py': { + res.setHeader('X-Request-Method', req.method) + res.setHeader('X-Request-Content-Length', req.headers['content-length'] ?? 'NO') + res.setHeader('X-Request-Content-Type', req.headers['content-type'] ?? 'NO') + res.setHeader('Content-Type', 'text/plain') + + for await (const chunk of req) { + res.write(chunk) + } + + res.end() + break + } + case '/fetch/api/resources/status.py': { + const code = parseInt(fullUrl.searchParams.get('code') ?? 200) + const text = fullUrl.searchParams.get('text') ?? 'OMG' + const content = fullUrl.searchParams.get('content') ?? '' + const type = fullUrl.searchParams.get('type') ?? '' + res.statusCode = code + res.statusMessage = text + res.setHeader('Content-Type', type) + res.setHeader('X-Request-Method', req.method) + res.end(content) + break + } + case '/fetch/api/resources/inspect-headers.py': { + const query = fullUrl.searchParams + const checkedHeaders = query.get('headers') + ?.split('|') + .map(h => h.toLowerCase()) ?? [] + + if (query.has('headers')) { + for (const header of checkedHeaders) { + if (Object.hasOwn(req.headers, header)) { + res.setHeader(`x-request-${header}`, req.headers[header] ?? '') + } + } + } + + if (query.has('cors')) { + if (Object.hasOwn(req.headers, 'origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, HEAD') + const exposedHeaders = checkedHeaders.map(h => `x-request-${h}`).join(', ') + res.setHeader('Access-Control-Expose-Headers', exposedHeaders) + if (query.has('allow_headers')) { + res.setHeader('Access-Control-Allow-Headers', query.get('allowed_headers')) + } else { + res.setHeader('Access-Control-Allow-Headers', Object.keys(req.headers).join(', ')) + } + } + + res.setHeader('content-type', 'text/plain') + res.end('') + break + } + case '/xhr/resources/parse-headers.py': { + if (fullUrl.searchParams.has('my-custom-header')) { + const val = fullUrl.searchParams.get('my-custom-header').toLowerCase() + // res.setHeader does validation which may prevent some tests from running. + res.socket.write( + `HTTP/1.1 200 OK\r\nmy-custom-header: ${val}\r\n\r\n` + ) + } + res.end('') + break + } + case '/fetch/api/resources/bad-chunk-encoding.py': { + const query = fullUrl.searchParams + + const delay = parseFloat(query.get('ms') ?? 1000) + const count = parseInt(query.get('count') ?? 50) + await sleep(delay) + res.socket.write( + 'HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n' + ) + await sleep(delay) + + for (let i = 0; i < count; i++) { + res.socket.write('a\r\nTEST_CHUNK\r\n') + await sleep(delay) + } + + res.end('garbage') + break + } + case '/xhr/resources/headers-www-authenticate.asis': + case '/xhr/resources/headers-some-are-empty.asis': + case '/xhr/resources/headers-basic': + case '/xhr/resources/headers-double-empty.asis': + case '/xhr/resources/header-content-length-twice.asis': + case '/xhr/resources/header-content-length.asis': { + let asis = readFileSync(join(tests, fullUrl.pathname), 'utf-8') + asis = asis.replace(/\n/g, '\r\n') + asis = `${asis}\r\n` + + res.socket.write(asis) + res.end() + break + } + case '/fetch/connection-pool/resources/network-partition-key.py': { + return networkPartitionRoute(req, res, fullUrl) + } + case '/resources/top.txt': { + return createReadStream(join(tests, 'fetch/api/', fullUrl.pathname)) + .on('end', () => res.end()) + .pipe(res) + } + case '/fetch/api/resources/redirect.py': { + return redirectRoute(req, res, fullUrl) + } + case '/fetch/api/resources/method.py': { + if (fullUrl.searchParams.has('cors')) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, FOO') + res.setHeader('Access-Control-Allow-Headers', 'x-test, x-foo') + res.setHeader('Access-Control-Expose-Headers', 'x-request-method') + } + + res.setHeader('x-request-method', req.method) + res.setHeader('x-request-content-type', req.headers['content-type'] ?? 'NO') + res.setHeader('x-request-content-length', req.headers['content-length'] ?? 'NO') + res.setHeader('x-request-content-encoding', req.headers['content-encoding'] ?? 'NO') + res.setHeader('x-request-content-language', req.headers['content-language'] ?? 'NO') + res.setHeader('x-request-content-location', req.headers['content-location'] ?? 'NO') + + for await (const chunk of req) { + res.write(chunk) + } + + res.end() + return + } + case '/fetch/api/resources/clean-stash.py': { + const token = fullUrl.searchParams.get('token') + const took = stash.take(token) + + if (took) { + res.end('1') + } else { + res.end('0') + } + + break + } + case '/fetch/content-encoding/resources/bad-gzip-body.py': { + res.setHeader('Content-Encoding', 'gzip') + res.end('not actually gzip') + break + } + case '/fetch/api/resources/dump-authorization-header.py': { + res.setHeader('Content-Type', 'text/html') + res.setHeader('Cache-Control', 'no-cache') + + if (req.headers.origin) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + res.setHeader('Access-Control-Allow-Headers', 'Authorization') + res.statusCode = 200 + + if (req.headers.authorization) { + res.end(req.headers.authorization) + return + } + + res.end('none') + break + } + case '/xhr/resources/echo-headers.py': { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + + // wpt runner sends this as 1 chunk + let body = '' + + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const key = req.rawHeaders[i] + const value = req.rawHeaders[i + 1] + + body += `${key}: ${value}` + } + + res.end(body) + break + } + case '/fetch/api/resources/authentication.py': { + const auth = Buffer.from(req.headers.authorization.slice('Basic '.length), 'base64') + const [user, password] = auth.toString().split(':') + + if (user === 'user' && password === 'password') { + res.end('Authentication done') + return + } + + const realm = fullUrl.searchParams.get('realm') ?? 'test' + + res.statusCode = 401 + res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`) + res.end('Please login with credentials \'user\' and \'password\'') + return + } + case '/fetch/api/resources/redirect-empty-location.py': { + res.setHeader('location', '') + res.statusCode = 302 + res.end('') + return + } + case '/service-workers/cache-storage/resources/fetch-status.py': { + const status = Number(fullUrl.searchParams.get('status')) + + res.statusCode = status + res.end() + return + } + default: { + res.statusCode = 200 + res.end(fullUrl.toString()) + } + } +}).listen(0) + +await once(server, 'listening') + +const send = (message) => { + if (typeof process.send === 'function') { + process.send(message) + } +} + +const url = `http://localhost:${server.address().port}` +console.log('server opened ' + url) +send({ server: url }) + +process.on('message', (message) => { + if (message === 'shutdown') { + server.close((err) => process.exit(err ? 1 : 0)) + } +}) + +export { server } diff --git a/test/wpt/server/websocket.mjs b/test/wpt/server/websocket.mjs new file mode 100644 index 0000000..cc8ce78 --- /dev/null +++ b/test/wpt/server/websocket.mjs @@ -0,0 +1,46 @@ +import { WebSocketServer } from 'ws' +import { server } from './server.mjs' + +// The file router server handles sending the url, closing, +// and sending messages back to the main process for us. +// The types for WebSocketServer don't include a `request` +// event, so I'm unsure if we can stop relying on server. + +const wss = new WebSocketServer({ + server, + handleProtocols: (protocols) => [...protocols].join(', ') +}) + +wss.on('connection', (ws, request) => { + ws.on('message', (data, isBinary) => { + const str = data.toString('utf-8') + + if (request.url === '/receive-many-with-backpressure') { + setTimeout(() => { + ws.send(str.length.toString(), { binary: false }) + }, 100) + return + } + + if (str === 'Goodbye') { + // Close-server-initiated-close.any.js sends a "Goodbye" message + // when it wants the server to close the connection. + ws.close(1000) + return + } + + ws.send(data, { binary: isBinary }) + }) + + // Some tests, such as `Create-blocked-port.any.js` do NOT + // close the connection automatically. + const timeout = setTimeout(() => { + if (ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { + ws.close() + } + }, 2500) + + ws.on('close', () => { + clearTimeout(timeout) + }) +}) diff --git a/test/wpt/start-FileAPI.mjs b/test/wpt/start-FileAPI.mjs new file mode 100644 index 0000000..5a92ab8 --- /dev/null +++ b/test/wpt/start-FileAPI.mjs @@ -0,0 +1,26 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('FileAPI', message.server) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/start-cacheStorage.mjs b/test/wpt/start-cacheStorage.mjs new file mode 100644 index 0000000..a630e05 --- /dev/null +++ b/test/wpt/start-cacheStorage.mjs @@ -0,0 +1,26 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('service-workers/cache-storage', message.server) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/start-fetch.mjs b/test/wpt/start-fetch.mjs new file mode 100644 index 0000000..59c9f83 --- /dev/null +++ b/test/wpt/start-fetch.mjs @@ -0,0 +1,31 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const { WPT_REPORT } = process.env + +const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('fetch', message.server, { + appendReport: !!WPT_REPORT, + reportPath: WPT_REPORT + }) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/start-mimesniff.mjs b/test/wpt/start-mimesniff.mjs new file mode 100644 index 0000000..fbdb9bf --- /dev/null +++ b/test/wpt/start-mimesniff.mjs @@ -0,0 +1,31 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const { WPT_REPORT } = process.env + +const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('mimesniff', message.server, { + appendReport: !!WPT_REPORT, + reportPath: WPT_REPORT + }) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/start-websockets.mjs b/test/wpt/start-websockets.mjs new file mode 100644 index 0000000..79aa297 --- /dev/null +++ b/test/wpt/start-websockets.mjs @@ -0,0 +1,47 @@ +import { WPTRunner } from './runner/runner.mjs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { fork } from 'child_process' +import { on } from 'events' + +const { WPT_REPORT } = process.env + +function isGlobalAvailable () { + if (typeof WebSocket !== 'undefined') { + return true + } + + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v)) + + // TODO: keep this up to date when backports to earlier majors happen + return nodeMajor >= 21 || (nodeMajor === 20 && nodeMinor >= 10) +} + +if (process.env.CI) { + // TODO(@KhafraDev): figure out *why* these tests are flaky in the CI. + // process.exit(0) +} + +const serverPath = fileURLToPath(join(import.meta.url, '../server/websocket.mjs')) + +const child = fork(serverPath, [], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] +}) + +child.on('exit', (code) => process.exit(code)) + +for await (const [message] of on(child, 'message')) { + if (message.server) { + const runner = new WPTRunner('websockets', message.server, { + appendReport: !!WPT_REPORT && isGlobalAvailable(), + reportPath: WPT_REPORT + }) + runner.run() + + runner.once('completion', () => { + if (child.connected) { + child.send('shutdown') + } + }) + } +} diff --git a/test/wpt/start-xhr.mjs b/test/wpt/start-xhr.mjs new file mode 100644 index 0000000..08f82eb --- /dev/null +++ b/test/wpt/start-xhr.mjs @@ -0,0 +1,12 @@ +import { WPTRunner } from './runner/runner.mjs' +import { once } from 'events' + +const { WPT_REPORT } = process.env + +const runner = new WPTRunner('xhr/formdata', 'http://localhost:3333', { + appendReport: !!WPT_REPORT, + reportPath: WPT_REPORT +}) +runner.run() + +await once(runner, 'completion') diff --git a/test/wpt/status/FileAPI.status.json b/test/wpt/status/FileAPI.status.json new file mode 100644 index 0000000..c64d255 --- /dev/null +++ b/test/wpt/status/FileAPI.status.json @@ -0,0 +1,75 @@ +{ + "file": { + "File-constructor.any.js": { + "flaky": [ + "Using type in File constructor: nonparsable" + ] + } + }, + "blob": { + "Blob-constructor.any.js": { + "skip": true + }, + "Blob-stream.any.js": { + "fail": [ + "Reading Blob.stream() with BYOB reader" + ] + } + }, + "url": { + "url-with-xhr.any.js": { + "skip": true + }, + "url-with-fetch.any.js": { + "note": "needs investigation", + "fail": [ + "Only exact matches should revoke URLs, using fetch", + "Revoke blob URL after creating Request, will fetch", + "Revoke blob URL after creating Request, then clone Request, will fetch" + ] + }, + "url-format.any.js": { + "fail": [ + "Origin of Blob URL matches our origin", + "Blob URL parses correctly", + "Origin of Blob URL matches our origin for Files" + ] + } + }, + "reading-data-section": { + "filereader_result.any.js": { + "note": "has to do with html microtask queue being different than queueMicrotask", + "skip": true + }, + "filereader_events.any.js": { + "note": "has to do with html microtask queue being different than queueMicrotask", + "fail": [ + "events are dispatched in the correct order for an empty blob", + "events are dispatched in the correct order for a non-empty blob" + ] + } + }, + "idlharness.any.js": { + "note": "These flaky tests only fail in < node v19; add in a way to mark them as such eventually", + "flaky": [ + "Blob interface: attribute size", + "Blob interface: attribute type", + "Blob interface: operation slice(optional long long, optional long long, optional DOMString)", + "Blob interface: operation stream()", + "Blob interface: operation text()", + "Blob interface: operation arrayBuffer()", + "URL interface: operation createObjectURL((Blob or MediaSource))", + "URL interface: operation revokeObjectURL(DOMString)" + ], + "fail": [ + "FileList interface: existence and properties of interface object", + "FileList interface object length", + "FileList interface object name", + "FileList interface: existence and properties of interface prototype object", + "FileList interface: existence and properties of interface prototype object's \"constructor\" property", + "FileList interface: existence and properties of interface prototype object's @@unscopables property", + "FileList interface: operation item(unsigned long)", + "FileList interface: attribute length" + ] + } +} diff --git a/test/wpt/status/fetch.status.json b/test/wpt/status/fetch.status.json new file mode 100644 index 0000000..5910bf3 --- /dev/null +++ b/test/wpt/status/fetch.status.json @@ -0,0 +1,457 @@ +{ + "api": { + "abort": { + "general.any.js": { + "note": "TODO(@KhafraDev): Clone aborts with original controller can probably be fixed", + "fail": [ + "Already aborted signal rejects immediately", + "Underlying connection is closed when aborting after receiving response - no-cors", + "Stream errors once aborted. Underlying connection closed.", + "Readable stream synchronously cancels with AbortError if aborted before reading", + "Clone aborts with original controller" + ] + }, + "cache.https.any.js": { + "note": "undici doesn't implement http caching", + "skip": true + } + }, + "basic": { + "conditional-get.any.js": { + "fail": [ + "Testing conditional GET with ETags" + ] + }, + "header-value-combining.any.js": { + "fail": [ + "response.headers.get('content-length') expects 0, 0", + "response.headers.get('foo-test') expects 1, 2, 3", + "response.headers.get('heya') expects , \\x0B\f, 1, , , 2" + ], + "flaky": [ + "response.headers.get('content-length') expects 0", + "response.headers.get('double-trouble') expects , ", + "response.headers.get('www-authenticate') expects 1, 2, 3, 4" + ] + }, + "integrity.sub.any.js": { + "fail": [ + "Empty string integrity for opaque response" + ] + }, + "keepalive.any.js": { + "note": "document is not defined", + "skip": true + }, + "mode-no-cors.sub.any.js": { + "note": "undici doesn't implement CORs", + "skip": true + }, + "mode-same-origin.any.js": { + "note": "undici doesn't respect RequestInit.mode", + "skip": true + }, + "referrer.any.js": { + "fail": [ + "origin-when-cross-origin policy on a cross-origin URL", + "origin-when-cross-origin policy on a cross-origin URL after same-origin redirection", + "origin-when-cross-origin policy on a same-origin URL after cross-origin redirection", + "origin-when-cross-origin policy on a same-origin URL" + ] + }, + "request-forbidden-headers.any.js": { + "note": "undici doesn't filter headers", + "skip": true + }, + "request-headers.any.js": { + "fail": [ + "Fetch with Chicken", + "Fetch with Chicken with body", + "Fetch with TacO and mode \"same-origin\" needs an Origin header", + "Fetch with TacO and mode \"cors\" needs an Origin header" + ] + }, + "request-referrer.any.js": { + "note": "TODO(@KhafraDev): url referrer test could probably be fixed", + "fail": [ + "about:client referrer", + "url referrer" + ] + }, + "request-upload.any.js": { + "fail": [ + "Fetch with POST with text body on 421 response should be retried once on new connection." + ] + }, + "request-upload.h2.any.js": { + "note": "undici doesn't support http/2", + "skip": true + }, + "status.h2.any.js": { + "note": "undici doesn't support http/2", + "skip": true + }, + "stream-safe-creation.any.js": { + "note": "tests are very finnicky", + "fail": [ + "throwing Object.prototype.type accessor should not affect stream creation by 'fetch'", + "Object.prototype.type accessor returning invalid value should not affect stream creation by 'fetch'", + "throwing Object.prototype.highWaterMark accessor should not affect stream creation by 'fetch'", + "Object.prototype.highWaterMark accessor returning invalid value should not affect stream creation by 'fetch'" + ] + } + }, + "body": { + "mime-type.any.js": { + "note": "fails on all platforms, https://wpt.fyi/results/fetch/api/body/mime-type.any.html?label=master&label=experimental&product=chrome&product=firefox&product=safari&product=node.js&product=deno&aligned", + "fail": [ + "Response: Extract a MIME type with clone" + ] + } + }, + "cors": { + "note": "undici doesn't implement CORs", + "skip": true + }, + "credentials": { + "authentication-redirection.any.js": { + "note": "connects to https server", + "fail": [ + "getAuthorizationHeaderValue - cross origin redirection", + "getAuthorizationHeaderValue - same origin redirection" + ] + }, + "cookies.any.js": { + "fail": [ + "Include mode: 1 cookie", + "Include mode: 2 cookies", + "Same-origin mode: 1 cookie", + "Same-origin mode: 2 cookies" + ] + } + }, + "fetch-later": { + "note": "this is not part of the spec, only a proposal", + "skip": true + }, + "headers": { + "header-setcookie.any.js": { + "note": "undici doesn't filter headers", + "fail": [ + "Set-Cookie is a forbidden response header" + ] + }, + "header-values-normalize.any.js": { + "note": "TODO(@KhafraDev): https://github.com/nodejs/undici/issues/1680", + "fail": [ + "XMLHttpRequest with value %00", + "XMLHttpRequest with value %01", + "XMLHttpRequest with value %02", + "XMLHttpRequest with value %03", + "XMLHttpRequest with value %04", + "XMLHttpRequest with value %05", + "XMLHttpRequest with value %06", + "XMLHttpRequest with value %07", + "XMLHttpRequest with value %08", + "XMLHttpRequest with value %09", + "XMLHttpRequest with value %0A", + "XMLHttpRequest with value %0D", + "XMLHttpRequest with value %0E", + "XMLHttpRequest with value %0F", + "XMLHttpRequest with value %10", + "XMLHttpRequest with value %11", + "XMLHttpRequest with value %12", + "XMLHttpRequest with value %13", + "XMLHttpRequest with value %14", + "XMLHttpRequest with value %15", + "XMLHttpRequest with value %16", + "XMLHttpRequest with value %17", + "XMLHttpRequest with value %18", + "XMLHttpRequest with value %19", + "XMLHttpRequest with value %1A", + "XMLHttpRequest with value %1B", + "XMLHttpRequest with value %1C", + "XMLHttpRequest with value %1D", + "XMLHttpRequest with value %1E", + "XMLHttpRequest with value %1F", + "XMLHttpRequest with value %20", + "fetch() with value %01", + "fetch() with value %02", + "fetch() with value %03", + "fetch() with value %04", + "fetch() with value %05", + "fetch() with value %06", + "fetch() with value %07", + "fetch() with value %08", + "fetch() with value %0E", + "fetch() with value %0F", + "fetch() with value %10", + "fetch() with value %11", + "fetch() with value %12", + "fetch() with value %13", + "fetch() with value %14", + "fetch() with value %15", + "fetch() with value %16", + "fetch() with value %17", + "fetch() with value %18", + "fetch() with value %19", + "fetch() with value %1A", + "fetch() with value %1B", + "fetch() with value %1C", + "fetch() with value %1D", + "fetch() with value %1E", + "fetch() with value %1F" + ] + }, + "header-values.any.js": { + "fail": [ + "XMLHttpRequest with value x%00x needs to throw", + "XMLHttpRequest with value x%0Ax needs to throw", + "XMLHttpRequest with value x%0Dx needs to throw", + "XMLHttpRequest with all valid values", + "fetch() with all valid values" + ] + }, + "headers-no-cors.any.js": { + "note": "undici doesn't implement CORs", + "skip": true + } + }, + "redirect": { + "redirect-empty-location.any.js": { + "note": "undici handles redirect: manual differently than browsers", + "fail": [ + "redirect response with empty Location, manual mode" + ] + }, + "redirect-keepalive.any.js": { + "note": "document is not defined", + "skip": true + }, + "redirect-location-escape.tentative.any.js": { + "note": "TODO(@KhafraDev): crashes runner", + "skip": true + }, + "redirect-location.any.js": { + "note": "undici handles redirect: manual differently than browsers", + "fail": [ + "Redirect 301 in \"manual\" mode without location", + "Redirect 301 in \"manual\" mode with invalid location", + "Redirect 301 in \"manual\" mode with data location", + "Redirect 302 in \"manual\" mode without location", + "Redirect 302 in \"manual\" mode with invalid location", + "Redirect 302 in \"manual\" mode with data location", + "Redirect 303 in \"manual\" mode without location", + "Redirect 303 in \"manual\" mode with invalid location", + "Redirect 303 in \"manual\" mode with data location", + "Redirect 307 in \"manual\" mode without location", + "Redirect 307 in \"manual\" mode with invalid location", + "Redirect 307 in \"manual\" mode with data location", + "Redirect 308 in \"manual\" mode without location", + "Redirect 308 in \"manual\" mode with invalid location", + "Redirect 308 in \"manual\" mode with data location", + "Redirect 301 in \"manual\" mode with valid location", + "Redirect 302 in \"manual\" mode with valid location", + "Redirect 303 in \"manual\" mode with valid location", + "Redirect 307 in \"manual\" mode with valid location", + "Redirect 308 in \"manual\" mode with valid location" + ] + }, + "redirect-method.any.js": { + "fail": [ + "Redirect 303 with TESTING" + ] + }, + "redirect-mode.any.js": { + "note": "mode isn't respected", + "skip": true + }, + "redirect-origin.any.js": { + "note": "TODO(@KhafraDev): investigate", + "skip": true + }, + "redirect-referrer-override.any.js": { + "note": "TODO(@KhafraDev): investigate", + "skip": true + }, + "redirect-referrer.any.js": { + "note": "TODO(@KhafraDev): investigate", + "skip": true + }, + "redirect-upload.h2.any.js": { + "note": "undici doesn't support http/2", + "skip": true + } + }, + "request": { + "request-cache-default-conditional.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-default.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-force-cache.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-no-cache.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-no-store.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-only-if-cached.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-cache-reload.any.js": { + "note": "undici doesn't implement an http cache", + "skip": true + }, + "request-consume-empty.any.js": { + "note": "the semantics about this test are being discussed - https://github.com/web-platform-tests/wpt/pull/3950", + "fail": [ + "Consume empty FormData request body as text" + ] + }, + "request-disturbed.any.js": { + "note": "this test fails in all other platforms - https://wpt.fyi/results/fetch/api/request/request-disturbed.any.html?label=master&label=experimental&product=chrome&product=firefox&product=safari&product=deno&aligned&view=subtest", + "fail": [ + "Input request used for creating new request became disturbed even if body is not used" + ] + }, + "request-headers.any.js": { + "note": "undici doesn't filter headers", + "fail": [ + "Adding invalid request header \"Accept-Charset: KO\"", + "Adding invalid request header \"accept-charset: KO\"", + "Adding invalid request header \"ACCEPT-ENCODING: KO\"", + "Adding invalid request header \"Accept-Encoding: KO\"", + "Adding invalid request header \"Access-Control-Request-Headers: KO\"", + "Adding invalid request header \"Access-Control-Request-Method: KO\"", + "Adding invalid request header \"Access-Control-Request-Private-Network: KO\"", + "Adding invalid request header \"Connection: KO\"", + "Adding invalid request header \"Content-Length: KO\"", + "Adding invalid request header \"Cookie: KO\"", + "Adding invalid request header \"Cookie2: KO\"", + "Adding invalid request header \"Date: KO\"", + "Adding invalid request header \"DNT: KO\"", + "Adding invalid request header \"Expect: KO\"", + "Adding invalid request header \"Host: KO\"", + "Adding invalid request header \"Keep-Alive: KO\"", + "Adding invalid request header \"Origin: KO\"", + "Adding invalid request header \"Referer: KO\"", + "Adding invalid request header \"Set-Cookie: KO\"", + "Adding invalid request header \"TE: KO\"", + "Adding invalid request header \"Trailer: KO\"", + "Adding invalid request header \"Transfer-Encoding: KO\"", + "Adding invalid request header \"Upgrade: KO\"", + "Adding invalid request header \"Via: KO\"", + "Adding invalid request header \"Proxy-: KO\"", + "Adding invalid request header \"proxy-a: KO\"", + "Adding invalid request header \"Sec-: KO\"", + "Adding invalid request header \"sec-b: KO\"", + "Adding invalid no-cors request header \"Content-Type: KO\"", + "Adding invalid no-cors request header \"Potato: KO\"", + "Adding invalid no-cors request header \"proxy: KO\"", + "Adding invalid no-cors request header \"proxya: KO\"", + "Adding invalid no-cors request header \"sec: KO\"", + "Adding invalid no-cors request header \"secb: KO\"", + "Adding invalid no-cors request header \"Empty-Value: \"", + "Check that request constructor is filtering headers provided as init parameter", + "Check that no-cors request constructor is filtering headers provided as init parameter", + "Check that no-cors request constructor is filtering headers provided as part of request parameter" + ] + }, + "request-init-priority.any.js": { + "note": "undici doesn't implement priority hints, yet(?)", + "skip": true + } + }, + "response": { + "response-clone.any.js": { + "fail": [ + "Check response clone use structureClone for teed ReadableStreams (ArrayBufferchunk)", + "Check response clone use structureClone for teed ReadableStreams (DataViewchunk)" + ] + }, + "response-consume-empty.any.js": { + "fail": [ + "Consume empty FormData response body as text" + ] + }, + "response-consume-stream.any.js": { + "fail": [ + "Read blob response's body as readableStream with mode=byob", + "Read text response's body as readableStream with mode=byob", + "Read URLSearchParams response's body as readableStream with mode=byob", + "Read array buffer response's body as readableStream with mode=byob", + "Read form data response's body as readableStream with mode=byob" + ] + }, + "response-error-from-stream.any.js": { + "fail": [ + "ReadableStream start() Error propagates to Response.formData() Promise", + "ReadableStream pull() Error propagates to Response.formData() Promise" + ] + }, + "response-stream-with-broken-then.any.js": { + "note": "this is a bug in webstreams, see https://github.com/nodejs/node/issues/46786", + "skip": true + } + } + }, + "content-length": { + "api-and-duplicate-headers.any.js": { + "fail": [ + "XMLHttpRequest and duplicate Content-Length/Content-Type headers", + "fetch() and duplicate Content-Length/Content-Type headers" + ] + } + }, + "cross-origin-resource-policy": { + "note": "undici doesn't implement CORs", + "skip": true + }, + "http-cache": { + "note": "undici doesn't implement http caching", + "skip": true + }, + "metadata": { + "note": "undici doesn't respect RequestInit.mode", + "skip": true + }, + "orb": { + "tentative": { + "note": "undici doesn't implement orb", + "skip": true + } + }, + "range": { + "note": "undici doesn't respect range header", + "skip": true + }, + "security": { + "1xx-response.any.js": { + "fail": [ + "Status(100) should be ignored.", + "Status(101) should be accepted, with removing body.", + "Status(103) should be ignored.", + "Status(199) should be ignored." + ] + } + }, + "stale-while-revalidate": { + "note": "undici doesn't implement http caching", + "skip": true + }, + "idlharness.any.js": { + "flaky": [ + "Window interface: operation fetch(RequestInfo, optional RequestInit)" + ] + } +} diff --git a/test/wpt/status/mimesniff.status.json b/test/wpt/status/mimesniff.status.json new file mode 100644 index 0000000..ab9a3d3 --- /dev/null +++ b/test/wpt/status/mimesniff.status.json @@ -0,0 +1,7 @@ +{ + "mime-types": { + "parsing.any.js": { + "allowUnexpectedFailures": true + } + } +} diff --git a/test/wpt/status/service-workers/cache-storage.status.json b/test/wpt/status/service-workers/cache-storage.status.json new file mode 100644 index 0000000..09a291e --- /dev/null +++ b/test/wpt/status/service-workers/cache-storage.status.json @@ -0,0 +1,24 @@ +{ + "cache-storage": { + "cache-abort.https.any.js": { + "skip": true + }, + "cache-storage-buckets.https.any.js": { + "skip": true, + "note": "navigator is not defined" + }, + "cache-put.https.any.js": { + "note": "probably can be fixed", + "fail": [ + "Cache.put with a VARY:* opaque response should not reject", + "Cache.put with opaque-filtered HTTP 206 response" + ] + }, + "cache-match.https.any.js": { + "note": "requires https server", + "fail": [ + "cors-exposed header should be stored correctly." + ] + } + } +} diff --git a/test/wpt/status/websockets.status.json b/test/wpt/status/websockets.status.json new file mode 100644 index 0000000..68bc6e2 --- /dev/null +++ b/test/wpt/status/websockets.status.json @@ -0,0 +1,115 @@ +{ + "stream": { + "tentative": { + "skip": true + } + }, + "Create-blocked-port.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Basic check" + ] + }, + "Send-binary-arraybufferview-float32.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Float32Array - Connection should be closed" + ] + }, + "Send-binary-arraybufferview-float64.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Float64Array - Connection should be closed" + ] + }, + "Send-binary-arraybufferview-int16-offset.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Int16Array with offset - Connection should be closed" + ] + }, + "Send-binary-arraybufferview-int32.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Int32Array - Connection should be closed" + ] + }, + "Send-binary-arraybufferview-uint16-offset-length.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Uint16Array with offset and length - Connection should be closed" + ] + }, + "Send-binary-arraybufferview-uint32-offset.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "Send binary data on a WebSocket - ArrayBufferView - Uint32Array with offset - Connection should be closed" + ] + }, + "basic-auth.any.js": { + "note": "TODO(@KhafraDev): investigate failure", + "fail": [ + "HTTP basic authentication should work with WebSockets" + ] + }, + "Create-on-worker-shutdown.any.js": { + "skip": true, + "//": "Node.js workers are different from web workers & don't work with blob: urls" + }, + "Close-delayed.any.js": { + "skip": true + }, + "bufferedAmount-unchanged-by-sync-xhr.any.js": { + "skip": true, + "//": "Node.js doesn't have XMLHttpRequest nor does this test make sense regardless" + }, + "referrer.any.js": { + "skip": true + }, + "Send-binary-blob.any.js": { + "flaky": [ + "Send binary data on a WebSocket - Blob - Connection should be closed" + ] + }, + "Send-65K-data.any.js": { + "flaky": [ + "Send 65K data on a WebSocket - Connection should be closed" + ] + }, + "Send-binary-65K-arraybuffer.any.js": { + "flaky": [ + "Send 65K binary data on a WebSocket - ArrayBuffer - Connection should be closed" + ] + }, + "Send-0byte-data.any.js": { + "flaky": [ + "Send 0 byte data on a WebSocket - Connection should be closed" + ] + }, + "send-many-64K-messages-with-backpressure.any.js": { + "note": "probably flaky based on other flaky tests.", + "flaky": [ + "sending 50 messages of size 65536 with backpressure applied should not hang" + ] + }, + "back-forward-cache-with-closed-websocket-connection-ccns.tentative.window.js": { + "skip": true, + "note": "browser-only test" + }, + "back-forward-cache-with-closed-websocket-connection.window.js": { + "skip": true, + "note": "browser-only test" + }, + "back-forward-cache-with-open-websocket-connection-ccns.tentative.window.js": { + "skip": true, + "note": "browser-only test" + }, + "back-forward-cache-with-open-websocket-connection.window.js": { + "skip": true, + "note": "browser-only test" + }, + "mixed-content.https.any.js": { + "note": "node has no concept of origin, thus there is no 'secure' or 'insecure' contexts", + "skip": true + } +} diff --git a/test/wpt/status/xhr/formdata.status.json b/test/wpt/status/xhr/formdata.status.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/wpt/status/xhr/formdata.status.json @@ -0,0 +1 @@ +{} diff --git a/test/wpt/tests/.azure-pipelines.yml b/test/wpt/tests/.azure-pipelines.yml new file mode 100644 index 0000000..75a87df --- /dev/null +++ b/test/wpt/tests/.azure-pipelines.yml @@ -0,0 +1,595 @@ +# This is the configuration file for Azure Pipelines, used to run tests on +# macOS and Windows. Documentation to help understand this setup: +# https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema +# https://docs.microsoft.com/en-us/azure/devops/pipelines/build/triggers +# https://docs.microsoft.com/en-us/azure/devops/pipelines/process/multiple-phases +# https://docs.microsoft.com/en-us/azure/devops/pipelines/process/templates +# https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables +# https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/index +# +# In addition to this configuration file, some setup in the Azure DevOps +# project is required: +# - The "Build pull requests from forks of this repository" setting must be +# enabled: https://docs.microsoft.com/en-us/azure/devops/pipelines/repos/github#validate-contributions-from-forks + +trigger: +# These are all the branches referenced in the jobs that follow. +- epochs/daily +- epochs/three_hourly +- triggers/edge_stable +- triggers/edge_dev +- triggers/edge_canary +- triggers/safari_stable +- triggers/safari_preview +- triggers/wktr_preview + +# Set safaridriver_diagnose to true to enable safaridriver diagnostics. The +# logs won't appear in `./wpt run` output but will be uploaded as an artifact. +variables: + safaridriver_diagnose: false + +jobs: +# The affected tests jobs are unconditional for speed, as most PRs have one or +# more affected tests: https://github.com/web-platform-tests/wpt/issues/13936. +- job: affected_safari_preview + displayName: 'affected tests: Safari Technology Preview' + condition: eq(variables['Build.Reason'], 'PullRequest') + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/affected_tests.yml + parameters: + artifactName: 'safari-preview-affected-tests' +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: affected_safari_preview + artifactName: safari-preview-affected-tests + +- job: affected_without_changes_safari_preview + displayName: 'affected tests without changes: Safari Technology Preview' + condition: eq(variables['Build.Reason'], 'PullRequest') + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/affected_tests.yml + parameters: + checkoutCommit: 'HEAD^1' + affectedRange: 'HEAD@{1}' + artifactName: 'safari-preview-affected-tests-without-changes' +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: affected_without_changes_safari_preview + artifactName: safari-preview-affected-tests-without-changes + +# The decision jobs runs `./wpt test-jobs` to determine which jobs to run, +# and all following jobs wait for it to finish and depend on its output. +- job: decision + displayName: './wpt test-jobs' + condition: eq(variables['Build.Reason'], 'PullRequest') + pool: + vmImage: 'ubuntu-20.04' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - script: | + set -eux -o pipefail + git fetch --depth 50 --quiet origin master + ./wpt test-jobs | while read job; do + echo "$job" + echo "##vso[task.setvariable variable=$job;isOutput=true]true"; + done + name: test_jobs + displayName: 'Run ./wpt test-jobs' + +- job: infrastructure_mac + displayName: 'infrastructure/ tests: macOS' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wptrunner_infrastructure'] + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_fonts.yml + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/color_profile.yml + - template: tools/ci/azure/install_chrome.yml + - template: tools/ci/azure/install_firefox.yml + - template: tools/ci/azure/install_safari.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: | + set -eux -o pipefail + ./wpt run --yes --no-manifest-update --manifest MANIFEST.json --metadata infrastructure/metadata/ --log-mach - --log-mach-level info --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_macos_chrome.json --channel dev chrome infrastructure/ + condition: succeededOrFailed() + displayName: 'Run tests (Chrome Dev)' + - script: | + set -eux -o pipefail + ./wpt run --yes --no-manifest-update --manifest MANIFEST.json --metadata infrastructure/metadata/ --log-mach - --log-mach-level info --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_macos_firefox.json --channel nightly firefox infrastructure/ + condition: succeededOrFailed() + displayName: 'Run tests (Firefox Nightly)' + - script: | + set -eux -o pipefail + export SYSTEM_VERSION_COMPAT=0 + ./wpt run --yes --no-manifest-update --manifest MANIFEST.json --metadata infrastructure/metadata/ --log-mach - --log-mach-level info --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_macos_safari.json --channel preview safari infrastructure/ + condition: succeededOrFailed() + displayName: 'Run tests (Safari Technology Preview)' + - task: PublishBuildArtifacts@1 + condition: succeededOrFailed() + displayName: 'Publish results' + inputs: + artifactName: 'infrastructure-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml + +- job: tools_unittest_mac_py37 + displayName: 'tools/ unittests: macOS + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.tools_unittest'] + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + # TODO(#40525): Revert back to 3.7 once the Mac agent's Python v3.7 contains bz2 again. + versionSpec: '3.7.16' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/ + toxenv: py37 + +- job: tools_unittest_mac_py311 + displayName: 'tools/ unittests: macOS + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.tools_unittest'] + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/ + toxenv: py311 + +- job: wptrunner_unittest_mac_py37 + displayName: 'tools/wptrunner/ unittests: macOS + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wptrunner_unittest'] + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + # TODO(#40525): Revert back to 3.7 once the Mac agent's Python v3.7 contains bz2 again. + versionSpec: '3.7.16' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wptrunner/ + toxenv: py37 + +- job: wptrunner_unittest_mac_py311 + displayName: 'tools/wptrunner/ unittests: macOS + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wptrunner_unittest'] + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wptrunner/ + toxenv: py311 + +- job: wpt_integration_mac_py37 + displayName: 'tools/wpt/ tests: macOS + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wpt_integration'] + pool: + vmImage: 'macOS-13' + steps: + # full checkout required + - task: UsePythonVersion@0 + inputs: + # TODO(#40525): Revert back to 3.7 once the Mac agent's Python v3.7 contains bz2 again. + versionSpec: '3.7.16' + - template: tools/ci/azure/install_chrome.yml + - template: tools/ci/azure/install_firefox.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wpt/ + toxenv: py37 + +- job: wpt_integration_mac_py311 + displayName: 'tools/wpt/ tests: macOS + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wpt_integration'] + pool: + vmImage: 'macOS-13' + steps: + # full checkout required + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/install_chrome.yml + - template: tools/ci/azure/install_firefox.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wpt/ + toxenv: py311 + +- job: tools_unittest_win_py37 + displayName: 'tools/ unittests: Windows + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.tools_unittest'] + pool: + vmImage: 'windows-2019' + variables: + HYPOTHESIS_PROFILE: ci + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.7' + addToPath: false + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/ + toxenv: py37 + +- job: tools_unittest_win_py311 + displayName: 'tools/ unittests: Windows + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.tools_unittest'] + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + addToPath: false + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/ + toxenv: py311 + +- job: wptrunner_unittest_win_py37 + displayName: 'tools/wptrunner/ unittests: Windows + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wptrunner_unittest'] + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.7' + addToPath: false + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wptrunner/ + toxenv: py37 + +- job: wptrunner_unittest_win_py311 + displayName: 'tools/wptrunner/ unittests: Windows + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wptrunner_unittest'] + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + addToPath: false + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wptrunner/ + toxenv: py311 + +- job: wpt_integration_win_py37 + displayName: 'tools/wpt/ tests: Windows + Python 3.7' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wpt_integration'] + pool: + vmImage: 'windows-2019' + steps: + # full checkout required + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.7' + # currently just using the outdated Chrome/Firefox on the VM rather than + # figuring out how to install Chrome Dev channel on Windows + # - template: tools/ci/azure/install_chrome.yml + # - template: tools/ci/azure/install_firefox.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wpt/ + toxenv: py37 + +- job: wpt_integration_win_py311 + displayName: 'tools/wpt/ tests: Windows + Python 3.11' + dependsOn: decision + condition: dependencies.decision.outputs['test_jobs.wpt_integration'] + pool: + vmImage: 'windows-2019' + steps: + # full checkout required + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + # currently just using the outdated Chrome/Firefox on the VM rather than + # figuring out how to install Chrome Dev channel on Windows + # - template: tools/ci/azure/install_chrome.yml + # - template: tools/ci/azure/install_firefox.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - template: tools/ci/azure/tox_pytest.yml + parameters: + directory: tools/wpt/ + toxenv: py311 + +- job: results_edge_stable + displayName: 'all tests: Edge Stable' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/epochs/daily'), + eq(variables['Build.SourceBranch'], 'refs/heads/triggers/edge_stable'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_edge_stable'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/system_info.yml + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/install_edge.yml + parameters: + channel: stable + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: python ./wpt run --yes --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --install-fonts --this-chunk $(System.JobPositionInPhase) --total-chunks $(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel stable edgechromium + displayName: 'Run tests (Edge Stable)' + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'edge-stable-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_edge_stable + artifactName: edge-stable-results + +- job: results_edge_dev + displayName: 'all tests: Edge Dev' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/epochs/three_hourly'), + eq(variables['Build.SourceBranch'], 'refs/heads/triggers/edge_dev'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_edge_dev'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/system_info.yml + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/install_edge.yml + parameters: + channel: dev + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: python ./wpt run --yes --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --install-fonts --this-chunk $(System.JobPositionInPhase) --total-chunks $(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel dev edgechromium + displayName: 'Run tests (Edge Dev)' + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'edge-dev-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_edge_dev + artifactName: edge-dev-results + +- job: results_edge_canary + displayName: 'all tests: Edge Canary' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/epochs/weekly'), + eq(variables['Build.SourceBranch'], 'refs/heads/triggers/edge_canary'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_edge_canary'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'windows-2019' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/install_edge.yml + parameters: + channel: canary + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: python ./wpt run --yes --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --install-fonts --this-chunk $(System.JobPositionInPhase) --total-chunks $(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel canary edgechromium + displayName: 'Run tests (Edge Canary)' + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'edge-canary-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_edge_canary + artifactName: edge-canary-results + +- job: results_safari + displayName: 'all tests: Safari' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/epochs/daily'), + eq(variables['Build.SourceBranch'], 'refs/heads/triggers/safari_stable'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_safari'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/color_profile.yml + - template: tools/ci/azure/install_safari.yml + parameters: + channel: stable + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: | + set -eux -o pipefail + export SYSTEM_VERSION_COMPAT=0 + ./wpt run --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --this-chunk=$(System.JobPositionInPhase) --total-chunks=$(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel stable --kill-safari --max-restarts 100 safari + displayName: 'Run tests' + retryCountOnTaskFailure: 2 + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'safari-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_safari + artifactName: safari-results + +- job: results_safari_preview + displayName: 'all tests: Safari Technology Preview' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/epochs/three_hourly'), + eq(variables['Build.SourceBranch'], 'refs/heads/triggers/safari_preview'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_safari_preview'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/color_profile.yml + - template: tools/ci/azure/install_safari.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: | + set -eux -o pipefail + export SYSTEM_VERSION_COMPAT=0 + ./wpt run --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --this-chunk=$(System.JobPositionInPhase) --total-chunks=$(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel preview --kill-safari --max-restarts 100 safari + displayName: 'Run tests' + retryCountOnTaskFailure: 2 + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'safari-preview-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_safari_preview + artifactName: safari-preview-results + +- job: results_wktr_preview + displayName: 'all tests: WebKitTestRunner' + condition: | + or(eq(variables['Build.SourceBranch'], 'refs/heads/triggers/wktr_preview'), + and(eq(variables['Build.Reason'], 'Manual'), variables['run_all_wktr_preview'])) + strategy: + parallel: 8 # chosen to make runtime ~2h + timeoutInMinutes: 180 + pool: + vmImage: 'macOS-13' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + - template: tools/ci/azure/checkout.yml + - template: tools/ci/azure/pip_install.yml + parameters: + packages: virtualenv + - template: tools/ci/azure/install_certs.yml + - template: tools/ci/azure/color_profile.yml + - template: tools/ci/azure/update_hosts.yml + - template: tools/ci/azure/update_manifest.yml + - script: | + set -eux -o pipefail + export SYSTEM_VERSION_COMPAT=0 + ./wpt run --no-manifest-update --no-restart-on-unexpected --no-fail-on-unexpected --this-chunk=$(System.JobPositionInPhase) --total-chunks=$(System.TotalJobsInPhase) --chunk-type hash --log-wptreport $(Build.ArtifactStagingDirectory)/wpt_report_$(System.JobPositionInPhase).json --log-wptscreenshot $(Build.ArtifactStagingDirectory)/wpt_screenshot_$(System.JobPositionInPhase).txt --log-mach - --log-mach-level info --channel experimental --install-browser --yes wktr + displayName: 'Run tests' + - task: PublishBuildArtifacts@1 + displayName: 'Publish results' + inputs: + artifactName: 'wktr-preview-results' + - template: tools/ci/azure/publish_logs.yml + - template: tools/ci/azure/sysdiagnose.yml +- template: tools/ci/azure/fyi_hook.yml + parameters: + dependsOn: results_wktr_preview + artifactName: wktr-preview-results diff --git a/test/wpt/tests/.gitattributes b/test/wpt/tests/.gitattributes new file mode 100644 index 0000000..5c11e4e --- /dev/null +++ b/test/wpt/tests/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/test/wpt/tests/.gitignore b/test/wpt/tests/.gitignore new file mode 100644 index 0000000..061700a --- /dev/null +++ b/test/wpt/tests/.gitignore @@ -0,0 +1,52 @@ +# Python +*.py[co] +.cache/ +.coverage* +.mypy_cache/ +.pytest_cache/ +.tox/ +.virtualenv/ +_venv*/ +_virtualenv/ + +# Node +node_modules/ + +# WPT repo stuff +.wptcache/ +/MANIFEST.json +/_certs +/config.json + +# Files generated when regenerating pre-generated certs +/tools/certs/0*.pem +/tools/certs/index.txt* +/tools/certs/serial* + +# Various OS/editor specific files +*# +*.orig +*.rej +*.svn +*.sw[po] +*.xcodeproj +*Thumbs.db +*~ +.DS_Store +.directory* +.idea/ +.vscode/ +\#* +scratch + +# Testsuite-specific rules +/conformance-checkers/vnu.jar +/cors/resources/log.txt +/css/build-temp +/css/dist +/css/dist_last +/url/tools/IdnaTestV2.txt +/webaudio/idl/* + +# w3c-test.org PR-branch mirroring +/submissions/ diff --git a/test/wpt/tests/.mailmap b/test/wpt/tests/.mailmap new file mode 100644 index 0000000..5293948 --- /dev/null +++ b/test/wpt/tests/.mailmap @@ -0,0 +1,9 @@ +# People who've changed name: + +# Sam Sneddon: +Sam Sneddon +Sam Sneddon + +# Theresa O'Connor: +Theresa O'Connor +Theresa O'Connor diff --git a/test/wpt/tests/.taskcluster.yml b/test/wpt/tests/.taskcluster.yml new file mode 100644 index 0000000..c817999 --- /dev/null +++ b/test/wpt/tests/.taskcluster.yml @@ -0,0 +1,82 @@ +version: 1 +reporting: checks-v1 +policy: + pullRequests: public +tasks: + $let: + run_task: + $if: 'tasks_for == "github-push"' + then: + $if: 'event.ref in ["refs/heads/master", "refs/heads/epochs/daily", "refs/heads/epochs/weekly", "refs/heads/triggers/chrome_stable", "refs/heads/triggers/chrome_beta", "refs/heads/triggers/chrome_dev", "refs/heads/triggers/chrome_nightly", "refs/heads/triggers/firefox_stable", "refs/heads/triggers/firefox_beta", "refs/heads/triggers/firefox_nightly", "refs/heads/triggers/webkitgtk_minibrowser_stable", "refs/heads/triggers/webkitgtk_minibrowser_beta", "refs/heads/triggers/webkitgtk_minibrowser_nightly", "refs/heads/triggers/servo_nightly"]' + then: true + else: false + else: + $if: 'tasks_for == "github-pull-request"' + then: + $if: 'event.action in ["opened", "reopened", "synchronize"]' + then: true + else: false + else: false + in: + - $if: run_task + then: + $let: + event_str: {$json: {$eval: event}} + scopes: + $if: 'tasks_for == "github-push"' + then: + $let: + branch: + $if: "event.ref[:11] == 'refs/heads/'" + then: "${event.ref[11:]}" + else: "${event.ref}" + in: "assume:repo:github.com/${event.repository.full_name}:branch:${branch}" + else: "assume:repo:github.com/${event.repository.full_name}:pull-request" + rev: + $if: 'tasks_for == "github-pull-request"' + then: "refs/pull/${event.number}/merge" + else: "${event.after}" + owner: + $if: 'tasks_for == "github-push"' + then: + $if: 'event.pusher.email' + then: + $if: '"@" in event.pusher.email' + then: ${event.pusher.email} + else: web-platform-tests@users.noreply.github.com + else: web-platform-tests@users.noreply.github.com + else: web-platform-tests@users.noreply.github.com + in: + created: {$fromNow: ''} + deadline: {$fromNow: '24 hours'} + provisionerId: proj-wpt + workerType: ci + metadata: + name: "wpt-decision-task" + description: "The task that creates all of the other tasks in the task graph" + owner: ${owner} + source: ${event.repository.clone_url} + payload: + image: webplatformtests/wpt:0.54 + maxRunTime: 7200 + artifacts: + public/results: + path: /home/test/artifacts + type: directory + command: + - /bin/bash + - --login + - -c + - set -ex; + ~/start.sh + ${event.repository.clone_url} + ${rev}; + cd ~/web-platform-tests; + ./wpt tc-decision --tasks-path=/home/test/artifacts/tasks.json + features : + taskclusterProxy: true + scopes: + - ${scopes} + extra: + github_event: "${event_str}" + diff --git a/test/wpt/tests/CODEOWNERS b/test/wpt/tests/CODEOWNERS new file mode 100644 index 0000000..140e0c6 --- /dev/null +++ b/test/wpt/tests/CODEOWNERS @@ -0,0 +1,6 @@ +# Require review for changes that often need an RFC +/resources/testdriver* @web-platform-tests/wpt-core-team +/resources/testharness* @web-platform-tests/wpt-core-team + +# Prevent accidentally touching tools/third_party +/tools/third_party/ @web-platform-tests/wpt-core-team diff --git a/test/wpt/tests/CODE_OF_CONDUCT.md b/test/wpt/tests/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..dae98ee --- /dev/null +++ b/test/wpt/tests/CODE_OF_CONDUCT.md @@ -0,0 +1,138 @@ +# Code of Conduct + +Contact: a moderator ([see below](#moderators)), or a member of the WPT +community that you feel you can trust. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, sexual identity and +orientation, or any other dimension of diversity. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Moderators are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Moderators have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +Moderators are held to a higher standard than other community members. If a +moderator creates an inappropriate situation, they should expect less leeway +than others. + +## Scope + +This Code of Conduct applies within all community spaces, and also +applies when an individual is officially representing the community in +public spaces. Examples of representing our community include +the official Matrix channel (wpt:matrix.org); GitHub repositories under +the web-platform-tests organization; and the public-test-infra@w3.org +mailing list. + +There may arise situations where both the WPT code of conduct and that of +another organization (such as the WHATWG or W3C) may apply. +For example, a WPT-focused meeting at +[TPAC](https://www.w3.org/2002/09/TPOverview.html) would involve both the WPT +code of conduct and the [W3C code of ethics and professional +conduct](https://www.w3.org/Consortium/cepc/). +In such situations we operate under all code of conducts involved. +If you are placed in a situation where you feel this is inappropriate (e.g. if +you believe the code of conducts involved contradict one another), please +contact a [moderator](#moderators). + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the [moderators](#moderators). +All complaints will be reviewed and investigated promptly and fairly. + +All moderators are obligated to respect the privacy and security of the +reporter of any incident. + +Moderators will recuse themselves if they are directly involved in a report of +a code of conduct violation. + +## Enforcement Guidelines + +Moderators will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct. +These are not a consecutive set of steps; a single incident may be sufficient +enough to proceed straight to a temporary ban, for example. + +### 1. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 3. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Moderators + +This section lists the current moderators and how to reach them. + +* Nina Satragno - [nso@google.com](mailto:nso@google.com). Languages: English, Spanish. +* Boaz Sender - [boaz@bocoup.com](mailto:boaz@bocoup.com). Languages: English, Hebrew. +* Jory Burson - [jory@bocoup.education](mailto:jory@bocoup.education). Languages: English (fluent), Spanish (conversational). + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/test/wpt/tests/CONTRIBUTING.md b/test/wpt/tests/CONTRIBUTING.md new file mode 100644 index 0000000..cb80d8d --- /dev/null +++ b/test/wpt/tests/CONTRIBUTING.md @@ -0,0 +1,11 @@ +All contributions are licensed under the terms of the [3-Clause BSD License](LICENSE.md). + +Documentation +------------- + +See [web-platform-tests.org](https://web-platform-tests.org/). + +Code of Conduct +--------------- + +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/test/wpt/tests/FileAPI/Blob-methods-from-detached-frame.html b/test/wpt/tests/FileAPI/Blob-methods-from-detached-frame.html new file mode 100644 index 0000000..37efd5e --- /dev/null +++ b/test/wpt/tests/FileAPI/Blob-methods-from-detached-frame.html @@ -0,0 +1,59 @@ + + +Blob methods from detached frame work as expected + + + + + + diff --git a/test/wpt/tests/FileAPI/BlobURL/cross-partition.tentative.https.html b/test/wpt/tests/FileAPI/BlobURL/cross-partition.tentative.https.html new file mode 100644 index 0000000..c75ce07 --- /dev/null +++ b/test/wpt/tests/FileAPI/BlobURL/cross-partition.tentative.https.html @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + diff --git a/test/wpt/tests/FileAPI/BlobURL/support/file_test2.txt b/test/wpt/tests/FileAPI/BlobURL/support/file_test2.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/FileAPI/BlobURL/test2-manual.html b/test/wpt/tests/FileAPI/BlobURL/test2-manual.html new file mode 100644 index 0000000..07fb27e --- /dev/null +++ b/test/wpt/tests/FileAPI/BlobURL/test2-manual.html @@ -0,0 +1,62 @@ + + + + + Blob and File reference URL Test(2) + + + + + + +
+
+
+ +
+

Test steps:

+
    +
  1. Download the file.
  2. +
  3. Select the file in the file inputbox.
  4. +
  5. Delete the file.
  6. +
  7. Click the 'start' button.
  8. +
+
+ +
+ + + + diff --git a/test/wpt/tests/FileAPI/FileReader/progress_event_bubbles_cancelable.html b/test/wpt/tests/FileAPI/FileReader/progress_event_bubbles_cancelable.html new file mode 100644 index 0000000..6a03243 --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReader/progress_event_bubbles_cancelable.html @@ -0,0 +1,33 @@ + + +File API Test: Progress Event - bubbles, cancelable + + + + +
+ + diff --git a/test/wpt/tests/FileAPI/FileReader/support/file_test1.txt b/test/wpt/tests/FileAPI/FileReader/support/file_test1.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/FileAPI/FileReader/test_errors-manual.html b/test/wpt/tests/FileAPI/FileReader/test_errors-manual.html new file mode 100644 index 0000000..b8c3f84 --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReader/test_errors-manual.html @@ -0,0 +1,72 @@ + + + + + FileReader Errors Test + + + + + + +
+
+
+ +
+

Test steps:

+
    +
  1. Download the file.
  2. +
  3. Select the file in the file inputbox.
  4. +
  5. Delete the file.
  6. +
  7. Click the 'start' button.
  8. +
+
+ +
+ + + + diff --git a/test/wpt/tests/FileAPI/FileReader/test_notreadableerrors-manual.html b/test/wpt/tests/FileAPI/FileReader/test_notreadableerrors-manual.html new file mode 100644 index 0000000..46d7359 --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReader/test_notreadableerrors-manual.html @@ -0,0 +1,42 @@ + + +FileReader NotReadableError Test + + + + +
+
+
+ +
+

Test steps:

+
    +
  1. Download the file.
  2. +
  3. Select the file in the file inputbox.
  4. +
  5. Delete the file's readable permission.
  6. +
  7. Click the 'start' button.
  8. +
+
+ + + diff --git a/test/wpt/tests/FileAPI/FileReader/test_securityerrors-manual.html b/test/wpt/tests/FileAPI/FileReader/test_securityerrors-manual.html new file mode 100644 index 0000000..add93ed --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReader/test_securityerrors-manual.html @@ -0,0 +1,40 @@ + + +FileReader SecurityError Test + + + + +
+
+
+ +
+

Test steps:

+
    +
  1. Select a system sensitive file (e.g. files in /usr/bin, password files, + and other native operating system executables) in the file inputbox.
  2. +
  3. Click the 'start' button.
  4. +
+
+ + diff --git a/test/wpt/tests/FileAPI/FileReader/workers.html b/test/wpt/tests/FileAPI/FileReader/workers.html new file mode 100644 index 0000000..8e114ee --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReader/workers.html @@ -0,0 +1,27 @@ + + + + + diff --git a/test/wpt/tests/FileAPI/FileReaderSync.worker.js b/test/wpt/tests/FileAPI/FileReaderSync.worker.js new file mode 100644 index 0000000..3d7a022 --- /dev/null +++ b/test/wpt/tests/FileAPI/FileReaderSync.worker.js @@ -0,0 +1,56 @@ +importScripts("/resources/testharness.js"); + +var blob, empty_blob, readerSync; +setup(() => { + readerSync = new FileReaderSync(); + blob = new Blob(["test"]); + empty_blob = new Blob(); +}); + +test(() => { + assert_true(readerSync instanceof FileReaderSync); +}, "Interface"); + +test(() => { + var text = readerSync.readAsText(blob); + assert_equals(text, "test"); +}, "readAsText"); + +test(() => { + var text = readerSync.readAsText(empty_blob); + assert_equals(text, ""); +}, "readAsText with empty blob"); + +test(() => { + var data = readerSync.readAsDataURL(blob); + assert_equals(data.indexOf("data:"), 0); +}, "readAsDataURL"); + +test(() => { + var data = readerSync.readAsDataURL(empty_blob); + assert_equals(data.indexOf("data:"), 0); +}, "readAsDataURL with empty blob"); + +test(() => { + var data = readerSync.readAsBinaryString(blob); + assert_equals(data, "test"); +}, "readAsBinaryString"); + +test(() => { + var data = readerSync.readAsBinaryString(empty_blob); + assert_equals(data, ""); +}, "readAsBinaryString with empty blob"); + +test(() => { + var data = readerSync.readAsArrayBuffer(blob); + assert_true(data instanceof ArrayBuffer); + assert_equals(data.byteLength, "test".length); +}, "readAsArrayBuffer"); + +test(() => { + var data = readerSync.readAsArrayBuffer(empty_blob); + assert_true(data instanceof ArrayBuffer); + assert_equals(data.byteLength, 0); +}, "readAsArrayBuffer with empty blob"); + +done(); diff --git a/test/wpt/tests/FileAPI/META.yml b/test/wpt/tests/FileAPI/META.yml new file mode 100644 index 0000000..506a59f --- /dev/null +++ b/test/wpt/tests/FileAPI/META.yml @@ -0,0 +1,6 @@ +spec: https://w3c.github.io/FileAPI/ +suggested_reviewers: + - inexorabletash + - zqzhang + - jdm + - mkruisselbrink diff --git a/test/wpt/tests/FileAPI/blob/Blob-array-buffer.any.js b/test/wpt/tests/FileAPI/blob/Blob-array-buffer.any.js new file mode 100644 index 0000000..2310646 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-array-buffer.any.js @@ -0,0 +1,45 @@ +// META: title=Blob Array Buffer +// META: script=../support/Blob.js +'use strict'; + +promise_test(async () => { + const input_arr = new TextEncoder().encode("PASS"); + const blob = new Blob([input_arr]); + const array_buffer = await blob.arrayBuffer(); + assert_true(array_buffer instanceof ArrayBuffer); + assert_equals_typed_array(new Uint8Array(array_buffer), input_arr); +}, "Blob.arrayBuffer()") + +promise_test(async () => { + const input_arr = new TextEncoder().encode(""); + const blob = new Blob([input_arr]); + const array_buffer = await blob.arrayBuffer(); + assert_true(array_buffer instanceof ArrayBuffer); + assert_equals_typed_array(new Uint8Array(array_buffer), input_arr); +}, "Blob.arrayBuffer() empty Blob data") + +promise_test(async () => { + const input_arr = new TextEncoder().encode("\u08B8\u000a"); + const blob = new Blob([input_arr]); + const array_buffer = await blob.arrayBuffer(); + assert_equals_typed_array(new Uint8Array(array_buffer), input_arr); +}, "Blob.arrayBuffer() non-ascii input") + +promise_test(async () => { + const input_arr = [8, 241, 48, 123, 151]; + const typed_arr = new Uint8Array(input_arr); + const blob = new Blob([typed_arr]); + const array_buffer = await blob.arrayBuffer(); + assert_equals_typed_array(new Uint8Array(array_buffer), typed_arr); +}, "Blob.arrayBuffer() non-unicode input") + +promise_test(async () => { + const input_arr = new TextEncoder().encode("PASS"); + const blob = new Blob([input_arr]); + const array_buffer_results = await Promise.all([blob.arrayBuffer(), + blob.arrayBuffer(), blob.arrayBuffer()]); + for (let array_buffer of array_buffer_results) { + assert_true(array_buffer instanceof ArrayBuffer); + assert_equals_typed_array(new Uint8Array(array_buffer), input_arr); + } +}, "Blob.arrayBuffer() concurrent reads") diff --git a/test/wpt/tests/FileAPI/blob/Blob-constructor-dom.window.js b/test/wpt/tests/FileAPI/blob/Blob-constructor-dom.window.js new file mode 100644 index 0000000..4fd4a43 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-constructor-dom.window.js @@ -0,0 +1,53 @@ +// META: title=Blob constructor +// META: script=../support/Blob.js +'use strict'; + +var test_error = { + name: "test", + message: "test error", +}; + +test(function() { + var args = [ + document.createElement("div"), + window, + ]; + args.forEach(function(arg) { + assert_throws_js(TypeError, function() { + new Blob(arg); + }, "Should throw for argument " + format_value(arg) + "."); + }); +}, "Passing platform objects for blobParts should throw a TypeError."); + +test(function() { + var element = document.createElement("div"); + element.appendChild(document.createElement("div")); + element.appendChild(document.createElement("p")); + var list = element.children; + Object.defineProperty(list, "length", { + get: function() { throw test_error; } + }); + assert_throws_exactly(test_error, function() { + new Blob(list); + }); +}, "A platform object that supports indexed properties should be treated as a sequence for the blobParts argument (overwritten 'length'.)"); + +test_blob(function() { + var select = document.createElement("select"); + select.appendChild(document.createElement("option")); + return new Blob(select); +}, { + expected: "[object HTMLOptionElement]", + type: "", + desc: "Passing an platform object that supports indexed properties as the blobParts array should work (select)." +}); + +test_blob(function() { + var elm = document.createElement("div"); + elm.setAttribute("foo", "bar"); + return new Blob(elm.attributes); +}, { + expected: "[object Attr]", + type: "", + desc: "Passing an platform object that supports indexed properties as the blobParts array should work (attributes)." +}); \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/blob/Blob-constructor-endings.html b/test/wpt/tests/FileAPI/blob/Blob-constructor-endings.html new file mode 100644 index 0000000..04edd2a --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-constructor-endings.html @@ -0,0 +1,104 @@ + + +Blob constructor: endings option + + + + diff --git a/test/wpt/tests/FileAPI/blob/Blob-constructor.any.js b/test/wpt/tests/FileAPI/blob/Blob-constructor.any.js new file mode 100644 index 0000000..d16f760 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-constructor.any.js @@ -0,0 +1,468 @@ +// META: title=Blob constructor +// META: script=../support/Blob.js +'use strict'; + +test(function() { + assert_true("Blob" in globalThis, "globalThis should have a Blob property."); + assert_equals(Blob.length, 0, "Blob.length should be 0."); + assert_true(Blob instanceof Function, "Blob should be a function."); +}, "Blob interface object"); + +// Step 1. +test(function() { + var blob = new Blob(); + assert_true(blob instanceof Blob); + assert_equals(String(blob), '[object Blob]'); + assert_equals(blob.size, 0); + assert_equals(blob.type, ""); +}, "Blob constructor with no arguments"); +test(function() { + assert_throws_js(TypeError, function() { var blob = Blob(); }); +}, "Blob constructor with no arguments, without 'new'"); +test(function() { + var blob = new Blob; + assert_true(blob instanceof Blob); + assert_equals(blob.size, 0); + assert_equals(blob.type, ""); +}, "Blob constructor without brackets"); +test(function() { + var blob = new Blob(undefined); + assert_true(blob instanceof Blob); + assert_equals(String(blob), '[object Blob]'); + assert_equals(blob.size, 0); + assert_equals(blob.type, ""); +}, "Blob constructor with undefined as first argument"); + +// blobParts argument (WebIDL). +test(function() { + var args = [ + null, + true, + false, + 0, + 1, + 1.5, + "FAIL", + new Date(), + new RegExp(), + {}, + { 0: "FAIL", length: 1 }, + ]; + args.forEach(function(arg) { + assert_throws_js(TypeError, function() { + new Blob(arg); + }, "Should throw for argument " + format_value(arg) + "."); + }); +}, "Passing non-objects, Dates and RegExps for blobParts should throw a TypeError."); + +test_blob(function() { + return new Blob({ + [Symbol.iterator]: Array.prototype[Symbol.iterator], + }); +}, { + expected: "", + type: "", + desc: "A plain object with @@iterator should be treated as a sequence for the blobParts argument." +}); +test(t => { + const blob = new Blob({ + [Symbol.iterator]() { + var i = 0; + return {next: () => [ + {done:false, value:'ab'}, + {done:false, value:'cde'}, + {done:true} + ][i++] + }; + } + }); + assert_equals(blob.size, 5, 'Custom @@iterator should be treated as a sequence'); +}, "A plain object with custom @@iterator should be treated as a sequence for the blobParts argument."); +test_blob(function() { + return new Blob({ + [Symbol.iterator]: Array.prototype[Symbol.iterator], + 0: "PASS", + length: 1 + }); +}, { + expected: "PASS", + type: "", + desc: "A plain object with @@iterator and a length property should be treated as a sequence for the blobParts argument." +}); +test_blob(function() { + return new Blob(new String("xyz")); +}, { + expected: "xyz", + type: "", + desc: "A String object should be treated as a sequence for the blobParts argument." +}); +test_blob(function() { + return new Blob(new Uint8Array([1, 2, 3])); +}, { + expected: "123", + type: "", + desc: "A Uint8Array object should be treated as a sequence for the blobParts argument." +}); + +var test_error = { + name: "test", + message: "test error", +}; + +test(function() { + var obj = { + [Symbol.iterator]: Array.prototype[Symbol.iterator], + get length() { throw test_error; } + }; + assert_throws_exactly(test_error, function() { + new Blob(obj); + }); +}, "The length getter should be invoked and any exceptions should be propagated."); + +test(function() { + assert_throws_exactly(test_error, function() { + var obj = { + [Symbol.iterator]: Array.prototype[Symbol.iterator], + length: { + valueOf: null, + toString: function() { throw test_error; } + } + }; + new Blob(obj); + }); + assert_throws_exactly(test_error, function() { + var obj = { + [Symbol.iterator]: Array.prototype[Symbol.iterator], + length: { valueOf: function() { throw test_error; } } + }; + new Blob(obj); + }); +}, "ToUint32 should be applied to the length and any exceptions should be propagated."); + +test(function() { + var received = []; + var obj = { + get [Symbol.iterator]() { + received.push("Symbol.iterator"); + return Array.prototype[Symbol.iterator]; + }, + get length() { + received.push("length getter"); + return { + valueOf: function() { + received.push("length valueOf"); + return 3; + } + }; + }, + get 0() { + received.push("0 getter"); + return { + toString: function() { + received.push("0 toString"); + return "a"; + } + }; + }, + get 1() { + received.push("1 getter"); + throw test_error; + }, + get 2() { + received.push("2 getter"); + assert_unreached("Should not call the getter for 2 if the getter for 1 threw."); + } + }; + assert_throws_exactly(test_error, function() { + new Blob(obj); + }); + assert_array_equals(received, [ + "Symbol.iterator", + "length getter", + "length valueOf", + "0 getter", + "0 toString", + "length getter", + "length valueOf", + "1 getter", + ]); +}, "Getters and value conversions should happen in order until an exception is thrown."); + +// XXX should add tests edge cases of ToLength(length) + +test(function() { + assert_throws_exactly(test_error, function() { + new Blob([{ toString: function() { throw test_error; } }]); + }, "Throwing toString"); + assert_throws_exactly(test_error, function() { + new Blob([{ toString: undefined, valueOf: function() { throw test_error; } }]); + }, "Throwing valueOf"); + assert_throws_exactly(test_error, function() { + new Blob([{ + toString: function() { throw test_error; }, + valueOf: function() { assert_unreached("Should not call valueOf if toString is present."); } + }]); + }, "Throwing toString and valueOf"); + assert_throws_js(TypeError, function() { + new Blob([{toString: null, valueOf: null}]); + }, "Null toString and valueOf"); +}, "ToString should be called on elements of the blobParts array and any exceptions should be propagated."); + +test_blob(function() { + var arr = [ + { toString: function() { arr.pop(); return "PASS"; } }, + { toString: function() { assert_unreached("Should have removed the second element of the array rather than called toString() on it."); } } + ]; + return new Blob(arr); +}, { + expected: "PASS", + type: "", + desc: "Changes to the blobParts array should be reflected in the returned Blob (pop)." +}); + +test_blob(function() { + var arr = [ + { + toString: function() { + if (arr.length === 3) { + return "A"; + } + arr.unshift({ + toString: function() { + assert_unreached("Should only access index 0 once."); + } + }); + return "P"; + } + }, + { + toString: function() { + return "SS"; + } + } + ]; + return new Blob(arr); +}, { + expected: "PASS", + type: "", + desc: "Changes to the blobParts array should be reflected in the returned Blob (unshift)." +}); + +test_blob(function() { + // https://www.w3.org/Bugs/Public/show_bug.cgi?id=17652 + return new Blob([ + null, + undefined, + true, + false, + 0, + 1, + new String("stringobject"), + [], + ['x', 'y'], + {}, + { 0: "FAIL", length: 1 }, + { toString: function() { return "stringA"; } }, + { toString: undefined, valueOf: function() { return "stringB"; } }, + { valueOf: function() { assert_unreached("Should not call valueOf if toString is present on the prototype."); } } + ]); +}, { + expected: "nullundefinedtruefalse01stringobjectx,y[object Object][object Object]stringAstringB[object Object]", + type: "", + desc: "ToString should be called on elements of the blobParts array." +}); + +test_blob(function() { + return new Blob([ + new ArrayBuffer(8) + ]); +}, { + expected: "\0\0\0\0\0\0\0\0", + type: "", + desc: "ArrayBuffer elements of the blobParts array should be supported." +}); + +test_blob(function() { + return new Blob([ + new Uint8Array([0x50, 0x41, 0x53, 0x53]), + new Int8Array([0x50, 0x41, 0x53, 0x53]), + new Uint16Array([0x4150, 0x5353]), + new Int16Array([0x4150, 0x5353]), + new Uint32Array([0x53534150]), + new Int32Array([0x53534150]), + new Float32Array([0xD341500000]) + ]); +}, { + expected: "PASSPASSPASSPASSPASSPASSPASS", + type: "", + desc: "Passing typed arrays as elements of the blobParts array should work." +}); +test_blob(function() { + return new Blob([ + // 0x535 3415053534150 + // 0x535 = 0b010100110101 -> Sign = +, Exponent = 1333 - 1023 = 310 + // 0x13415053534150 * 2**(-52) + // ==> 0x13415053534150 * 2**258 = 2510297372767036725005267563121821874921913208671273727396467555337665343087229079989707079680 + new Float64Array([2510297372767036725005267563121821874921913208671273727396467555337665343087229079989707079680]) + ]); +}, { + expected: "PASSPASS", + type: "", + desc: "Passing a Float64Array as element of the blobParts array should work." +}); + +test_blob(function() { + return new Blob([ + new BigInt64Array([BigInt("0x5353415053534150")]), + new BigUint64Array([BigInt("0x5353415053534150")]) + ]); +}, { + expected: "PASSPASSPASSPASS", + type: "", + desc: "Passing BigInt typed arrays as elements of the blobParts array should work." +}); + +var t_ports = async_test("Passing a FrozenArray as the blobParts array should work (FrozenArray)."); +t_ports.step(function() { + var channel = new MessageChannel(); + channel.port2.onmessage = this.step_func(function(e) { + var b_ports = new Blob(e.ports); + assert_equals(b_ports.size, "[object MessagePort]".length); + this.done(); + }); + var channel2 = new MessageChannel(); + channel.port1.postMessage('', [channel2.port1]); +}); + +test_blob(function() { + var blob = new Blob(['foo']); + return new Blob([blob, blob]); +}, { + expected: "foofoo", + type: "", + desc: "Array with two blobs" +}); + +test_blob_binary(function() { + var view = new Uint8Array([0, 255, 0]); + return new Blob([view.buffer, view.buffer]); +}, { + expected: [0, 255, 0, 0, 255, 0], + type: "", + desc: "Array with two buffers" +}); + +test_blob_binary(function() { + var view = new Uint8Array([0, 255, 0, 4]); + var blob = new Blob([view, view]); + assert_equals(blob.size, 8); + var view1 = new Uint16Array(view.buffer, 2); + return new Blob([view1, view.buffer, view1]); +}, { + expected: [0, 4, 0, 255, 0, 4, 0, 4], + type: "", + desc: "Array with two bufferviews" +}); + +test_blob(function() { + var view = new Uint8Array([0]); + var blob = new Blob(["fo"]); + return new Blob([view.buffer, blob, "foo"]); +}, { + expected: "\0fofoo", + type: "", + desc: "Array with mixed types" +}); + +test(function() { + const accessed = []; + const stringified = []; + + new Blob([], { + get type() { accessed.push('type'); }, + get endings() { accessed.push('endings'); } + }); + new Blob([], { + type: { toString: () => { stringified.push('type'); return ''; } }, + endings: { toString: () => { stringified.push('endings'); return 'transparent'; } } + }); + assert_array_equals(accessed, ['endings', 'type']); + assert_array_equals(stringified, ['endings', 'type']); +}, "options properties should be accessed in lexicographic order."); + +test(function() { + assert_throws_exactly(test_error, function() { + new Blob( + [{ toString: function() { throw test_error } }], + { + get type() { assert_unreached("type getter should not be called."); } + } + ); + }); +}, "Arguments should be evaluated from left to right."); + +[ + null, + undefined, + {}, + { unrecognized: true }, + /regex/, + function() {} +].forEach(function(arg, idx) { + test_blob(function() { + return new Blob([], arg); + }, { + expected: "", + type: "", + desc: "Passing " + format_value(arg) + " (index " + idx + ") for options should use the defaults." + }); + test_blob(function() { + return new Blob(["\na\r\nb\n\rc\r"], arg); + }, { + expected: "\na\r\nb\n\rc\r", + type: "", + desc: "Passing " + format_value(arg) + " (index " + idx + ") for options should use the defaults (with newlines)." + }); +}); + +[ + 123, + 123.4, + true, + 'abc' +].forEach(arg => { + test(t => { + assert_throws_js(TypeError, () => new Blob([], arg), + 'Blob constructor should throw with invalid property bag'); + }, `Passing ${JSON.stringify(arg)} for options should throw`); +}); + +var type_tests = [ + // blobParts, type, expected type + [[], '', ''], + [[], 'a', 'a'], + [[], 'A', 'a'], + [[], 'text/html', 'text/html'], + [[], 'TEXT/HTML', 'text/html'], + [[], 'text/plain;charset=utf-8', 'text/plain;charset=utf-8'], + [[], '\u00E5', ''], + [[], '\uD801\uDC7E', ''], // U+1047E + [[], ' image/gif ', ' image/gif '], + [[], '\timage/gif\t', ''], + [[], 'image/gif;\u007f', ''], + [[], '\u0130mage/gif', ''], // uppercase i with dot + [[], '\u0131mage/gif', ''], // lowercase dotless i + [[], 'image/gif\u0000', ''], + // check that type isn't changed based on sniffing + [[0x3C, 0x48, 0x54, 0x4D, 0x4C, 0x3E], 'unknown/unknown', 'unknown/unknown'], // "" + [[0x00, 0xFF], 'text/plain', 'text/plain'], + [[0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 'image/png', 'image/png'], // "GIF89a" +]; + +type_tests.forEach(function(t) { + test(function() { + var arr = new Uint8Array([t[0]]).buffer; + var b = new Blob([arr], {type:t[1]}); + assert_equals(b.type, t[2]); + }, "Blob with type " + format_value(t[1])); +}); diff --git a/test/wpt/tests/FileAPI/blob/Blob-in-worker.worker.js b/test/wpt/tests/FileAPI/blob/Blob-in-worker.worker.js new file mode 100644 index 0000000..a0ca845 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-in-worker.worker.js @@ -0,0 +1,9 @@ +importScripts("/resources/testharness.js"); + +promise_test(async () => { + const data = "TEST"; + const blob = new Blob([data], {type: "text/plain"}); + assert_equals(await blob.text(), data); +}, 'Create Blob in Worker'); + +done(); diff --git a/test/wpt/tests/FileAPI/blob/Blob-slice-overflow.any.js b/test/wpt/tests/FileAPI/blob/Blob-slice-overflow.any.js new file mode 100644 index 0000000..388fd92 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-slice-overflow.any.js @@ -0,0 +1,32 @@ +// META: title=Blob slice overflow +'use strict'; + +var text = ''; + +for (var i = 0; i < 2000; ++i) { + text += 'A'; +} + +test(function() { + var blob = new Blob([text]); + var sliceBlob = blob.slice(-1, blob.size); + assert_equals(sliceBlob.size, 1, "Blob slice size"); +}, "slice start is negative, relativeStart will be max((size + start), 0)"); + +test(function() { + var blob = new Blob([text]); + var sliceBlob = blob.slice(blob.size + 1, blob.size); + assert_equals(sliceBlob.size, 0, "Blob slice size"); +}, "slice start is greater than blob size, relativeStart will be min(start, size)"); + +test(function() { + var blob = new Blob([text]); + var sliceBlob = blob.slice(blob.size - 2, -1); + assert_equals(sliceBlob.size, 1, "Blob slice size"); +}, "slice end is negative, relativeEnd will be max((size + end), 0)"); + +test(function() { + var blob = new Blob([text]); + var sliceBlob = blob.slice(blob.size - 2, blob.size + 999); + assert_equals(sliceBlob.size, 2, "Blob slice size"); +}, "slice end is greater than blob size, relativeEnd will be min(end, size)"); diff --git a/test/wpt/tests/FileAPI/blob/Blob-slice.any.js b/test/wpt/tests/FileAPI/blob/Blob-slice.any.js new file mode 100644 index 0000000..1f85d44 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-slice.any.js @@ -0,0 +1,231 @@ +// META: title=Blob slice +// META: script=../support/Blob.js +'use strict'; + +test_blob(function() { + var blobTemp = new Blob(["PASS"]); + return blobTemp.slice(); +}, { + expected: "PASS", + type: "", + desc: "no-argument Blob slice" +}); + +test(function() { + var blob1, blob2; + + test_blob(function() { + return blob1 = new Blob(["squiggle"]); + }, { + expected: "squiggle", + type: "", + desc: "blob1." + }); + + test_blob(function() { + return blob2 = new Blob(["steak"], {type: "content/type"}); + }, { + expected: "steak", + type: "content/type", + desc: "blob2." + }); + + test_blob(function() { + return new Blob().slice(0,0,null); + }, { + expected: "", + type: "null", + desc: "null type Blob slice" + }); + + test_blob(function() { + return new Blob().slice(0,0,undefined); + }, { + expected: "", + type: "", + desc: "undefined type Blob slice" + }); + + test_blob(function() { + return new Blob().slice(0,0); + }, { + expected: "", + type: "", + desc: "no type Blob slice" + }); + + var arrayBuffer = new ArrayBuffer(16); + var int8View = new Int8Array(arrayBuffer); + for (var i = 0; i < 16; i++) { + int8View[i] = i + 65; + } + + var testData = [ + [ + ["PASSSTRING"], + [{start: -6, contents: "STRING"}, + {start: -12, contents: "PASSSTRING"}, + {start: 4, contents: "STRING"}, + {start: 12, contents: ""}, + {start: 0, end: -6, contents: "PASS"}, + {start: 0, end: -12, contents: ""}, + {start: 0, end: 4, contents: "PASS"}, + {start: 0, end: 12, contents: "PASSSTRING"}, + {start: 7, end: 4, contents: ""}] + ], + + // Test 3 strings + [ + ["foo", "bar", "baz"], + [{start: 0, end: 9, contents: "foobarbaz"}, + {start: 0, end: 3, contents: "foo"}, + {start: 3, end: 9, contents: "barbaz"}, + {start: 6, end: 9, contents: "baz"}, + {start: 6, end: 12, contents: "baz"}, + {start: 0, end: 9, contents: "foobarbaz"}, + {start: 0, end: 11, contents: "foobarbaz"}, + {start: 10, end: 15, contents: ""}] + ], + + // Test string, Blob, string + [ + ["foo", blob1, "baz"], + [{start: 0, end: 3, contents: "foo"}, + {start: 3, end: 11, contents: "squiggle"}, + {start: 2, end: 4, contents: "os"}, + {start: 10, end: 12, contents: "eb"}] + ], + + // Test blob, string, blob + [ + [blob1, "foo", blob1], + [{start: 0, end: 8, contents: "squiggle"}, + {start: 7, end: 9, contents: "ef"}, + {start: 10, end: 12, contents: "os"}, + {start: 1, end: 4, contents: "qui"}, + {start: 12, end: 15, contents: "qui"}, + {start: 40, end: 60, contents: ""}] + ], + + // Test blobs all the way down + [ + [blob2, blob1, blob2], + [{start: 0, end: 5, contents: "steak"}, + {start: 5, end: 13, contents: "squiggle"}, + {start: 13, end: 18, contents: "steak"}, + {start: 1, end: 3, contents: "te"}, + {start: 6, end: 10, contents: "quig"}] + ], + + // Test an ArrayBufferView + [ + [int8View, blob1, "foo"], + [{start: 0, end: 8, contents: "ABCDEFGH"}, + {start: 8, end: 18, contents: "IJKLMNOPsq"}, + {start: 17, end: 20, contents: "qui"}, + {start: 4, end: 12, contents: "EFGHIJKL"}] + ], + + // Test a partial ArrayBufferView + [ + [new Uint8Array(arrayBuffer, 3, 5), blob1, "foo"], + [{start: 0, end: 8, contents: "DEFGHsqu"}, + {start: 8, end: 18, contents: "igglefoo"}, + {start: 4, end: 12, contents: "Hsquiggl"}] + ], + + // Test type coercion of a number + [ + [3, int8View, "foo"], + [{start: 0, end: 8, contents: "3ABCDEFG"}, + {start: 8, end: 18, contents: "HIJKLMNOPf"}, + {start: 17, end: 21, contents: "foo"}, + {start: 4, end: 12, contents: "DEFGHIJK"}] + ], + + [ + [(new Uint8Array([0, 255, 0])).buffer, + new Blob(['abcd']), + 'efgh', + 'ijklmnopqrstuvwxyz'], + [{start: 1, end: 4, contents: "\uFFFD\u0000a"}, + {start: 4, end: 8, contents: "bcde"}, + {start: 8, end: 12, contents: "fghi"}, + {start: 1, end: 12, contents: "\uFFFD\u0000abcdefghi"}] + ] + ]; + + testData.forEach(function(data, i) { + var blobs = data[0]; + var tests = data[1]; + tests.forEach(function(expectations, j) { + test(function() { + var blob = new Blob(blobs); + assert_true(blob instanceof Blob); + assert_false(blob instanceof File); + + test_blob(function() { + return expectations.end === undefined + ? blob.slice(expectations.start) + : blob.slice(expectations.start, expectations.end); + }, { + expected: expectations.contents, + type: "", + desc: "Slicing test: slice (" + i + "," + j + ")." + }); + }, "Slicing test (" + i + "," + j + ")."); + }); + }); +}, "Slices"); + +var invalidTypes = [ + "\xFF", + "te\x09xt/plain", + "te\x00xt/plain", + "te\x1Fxt/plain", + "te\x7Fxt/plain" +]; +invalidTypes.forEach(function(type) { + test_blob(function() { + var blob = new Blob(["PASS"]); + return blob.slice(0, 4, type); + }, { + expected: "PASS", + type: "", + desc: "Invalid contentType (" + format_value(type) + ")" + }); +}); + +var validTypes = [ + "te(xt/plain", + "te)xt/plain", + "text/plain", + "te@xt/plain", + "te,xt/plain", + "te;xt/plain", + "te:xt/plain", + "te\\xt/plain", + "te\"xt/plain", + "te/xt/plain", + "te[xt/plain", + "te]xt/plain", + "te?xt/plain", + "te=xt/plain", + "te{xt/plain", + "te}xt/plain", + "te\x20xt/plain", + "TEXT/PLAIN", + "text/plain;charset = UTF-8", + "text/plain;charset=UTF-8" +]; +validTypes.forEach(function(type) { + test_blob(function() { + var blob = new Blob(["PASS"]); + return blob.slice(0, 4, type); + }, { + expected: "PASS", + type: type.toLowerCase(), + desc: "Valid contentType (" + format_value(type) + ")" + }); +}); diff --git a/test/wpt/tests/FileAPI/blob/Blob-stream-byob-crash.html b/test/wpt/tests/FileAPI/blob/Blob-stream-byob-crash.html new file mode 100644 index 0000000..5992ed1 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-stream-byob-crash.html @@ -0,0 +1,11 @@ + + diff --git a/test/wpt/tests/FileAPI/blob/Blob-stream-sync-xhr-crash.html b/test/wpt/tests/FileAPI/blob/Blob-stream-sync-xhr-crash.html new file mode 100644 index 0000000..fe54fb6 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-stream-sync-xhr-crash.html @@ -0,0 +1,13 @@ + + diff --git a/test/wpt/tests/FileAPI/blob/Blob-stream.any.js b/test/wpt/tests/FileAPI/blob/Blob-stream.any.js new file mode 100644 index 0000000..87710a1 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-stream.any.js @@ -0,0 +1,83 @@ +// META: title=Blob Stream +// META: script=../support/Blob.js +// META: script=/common/gc.js +'use strict'; + +// Helper function that triggers garbage collection while reading a chunk +// if perform_gc is true. +async function read_and_gc(reader, perform_gc) { + // Passing Uint8Array for byte streams; non-byte streams will simply ignore it + const read_promise = reader.read(new Uint8Array(64)); + if (perform_gc) { + await garbageCollect(); + } + return read_promise; +} + +// Takes in a ReadableStream and reads from it until it is done, returning +// an array that contains the results of each read operation. If perform_gc +// is true, garbage collection is triggered while reading every chunk. +async function read_all_chunks(stream, { perform_gc = false, mode } = {}) { + assert_true(stream instanceof ReadableStream); + assert_true('getReader' in stream); + const reader = stream.getReader({ mode }); + + assert_true('read' in reader); + let read_value = await read_and_gc(reader, perform_gc); + + let out = []; + let i = 0; + while (!read_value.done) { + for (let val of read_value.value) { + out[i++] = val; + } + read_value = await read_and_gc(reader, perform_gc); + } + return out; +} + +promise_test(async () => { + const blob = new Blob(["PASS"]); + const stream = blob.stream(); + const chunks = await read_all_chunks(stream); + for (let [index, value] of chunks.entries()) { + assert_equals(value, "PASS".charCodeAt(index)); + } +}, "Blob.stream()") + +promise_test(async () => { + const blob = new Blob(); + const stream = blob.stream(); + const chunks = await read_all_chunks(stream); + assert_array_equals(chunks, []); +}, "Blob.stream() empty Blob") + +promise_test(async () => { + const input_arr = [8, 241, 48, 123, 151]; + const typed_arr = new Uint8Array(input_arr); + const blob = new Blob([typed_arr]); + const stream = blob.stream(); + const chunks = await read_all_chunks(stream); + assert_array_equals(chunks, input_arr); +}, "Blob.stream() non-unicode input") + +promise_test(async() => { + const input_arr = [8, 241, 48, 123, 151]; + const typed_arr = new Uint8Array(input_arr); + let blob = new Blob([typed_arr]); + const stream = blob.stream(); + blob = null; + await garbageCollect(); + const chunks = await read_all_chunks(stream, { perform_gc: true }); + assert_array_equals(chunks, input_arr); +}, "Blob.stream() garbage collection of blob shouldn't break stream" + + "consumption") + +promise_test(async () => { + const input_arr = [8, 241, 48, 123, 151]; + const typed_arr = new Uint8Array(input_arr); + let blob = new Blob([typed_arr]); + const stream = blob.stream(); + const chunks = await read_all_chunks(stream, { mode: "byob" }); + assert_array_equals(chunks, input_arr); +}, "Reading Blob.stream() with BYOB reader") diff --git a/test/wpt/tests/FileAPI/blob/Blob-text.any.js b/test/wpt/tests/FileAPI/blob/Blob-text.any.js new file mode 100644 index 0000000..d04fa97 --- /dev/null +++ b/test/wpt/tests/FileAPI/blob/Blob-text.any.js @@ -0,0 +1,64 @@ +// META: title=Blob Text +// META: script=../support/Blob.js +'use strict'; + +promise_test(async () => { + const blob = new Blob(["PASS"]); + const text = await blob.text(); + assert_equals(text, "PASS"); +}, "Blob.text()") + +promise_test(async () => { + const blob = new Blob(); + const text = await blob.text(); + assert_equals(text, ""); +}, "Blob.text() empty blob data") + +promise_test(async () => { + const blob = new Blob(["P", "A", "SS"]); + const text = await blob.text(); + assert_equals(text, "PASS"); +}, "Blob.text() multi-element array in constructor") + +promise_test(async () => { + const non_unicode = "\u0061\u030A"; + const input_arr = new TextEncoder().encode(non_unicode); + const blob = new Blob([input_arr]); + const text = await blob.text(); + assert_equals(text, non_unicode); +}, "Blob.text() non-unicode") + +promise_test(async () => { + const blob = new Blob(["PASS"], { type: "text/plain;charset=utf-16le" }); + const text = await blob.text(); + assert_equals(text, "PASS"); +}, "Blob.text() different charset param in type option") + +promise_test(async () => { + const non_unicode = "\u0061\u030A"; + const input_arr = new TextEncoder().encode(non_unicode); + const blob = new Blob([input_arr], { type: "text/plain;charset=utf-16le" }); + const text = await blob.text(); + assert_equals(text, non_unicode); +}, "Blob.text() different charset param with non-ascii input") + +promise_test(async () => { + const input_arr = new Uint8Array([192, 193, 245, 246, 247, 248, 249, 250, 251, + 252, 253, 254, 255]); + const blob = new Blob([input_arr]); + const text = await blob.text(); + assert_equals(text, "\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd" + + "\ufffd\ufffd\ufffd\ufffd"); +}, "Blob.text() invalid utf-8 input") + +promise_test(async () => { + const input_arr = new Uint8Array([192, 193, 245, 246, 247, 248, 249, 250, 251, + 252, 253, 254, 255]); + const blob = new Blob([input_arr]); + const text_results = await Promise.all([blob.text(), blob.text(), + blob.text()]); + for (let text of text_results) { + assert_equals(text, "\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd" + + "\ufffd\ufffd\ufffd\ufffd"); + } +}, "Blob.text() concurrent reads") diff --git a/test/wpt/tests/FileAPI/file/File-constructor-endings.html b/test/wpt/tests/FileAPI/file/File-constructor-endings.html new file mode 100644 index 0000000..1282b6c --- /dev/null +++ b/test/wpt/tests/FileAPI/file/File-constructor-endings.html @@ -0,0 +1,104 @@ + + +File constructor: endings option + + + + diff --git a/test/wpt/tests/FileAPI/file/File-constructor.any.js b/test/wpt/tests/FileAPI/file/File-constructor.any.js new file mode 100644 index 0000000..0b0185c --- /dev/null +++ b/test/wpt/tests/FileAPI/file/File-constructor.any.js @@ -0,0 +1,155 @@ +// META: title=File constructor + +const to_string_obj = { toString: () => 'a string' }; +const to_string_throws = { toString: () => { throw new Error('expected'); } }; + +test(function() { + assert_true("File" in globalThis, "globalThis should have a File property."); +}, "File interface object exists"); + +test(t => { + assert_throws_js(TypeError, () => new File(), + 'Bits argument is required'); + assert_throws_js(TypeError, () => new File([]), + 'Name argument is required'); +}, 'Required arguments'); + +function test_first_argument(arg1, expectedSize, testName) { + test(function() { + var file = new File(arg1, "dummy"); + assert_true(file instanceof File); + assert_equals(file.name, "dummy"); + assert_equals(file.size, expectedSize); + assert_equals(file.type, ""); + // assert_false(file.isClosed); XXX: File.isClosed doesn't seem to be implemented + assert_not_equals(file.lastModified, ""); + }, testName); +} + +test_first_argument([], 0, "empty fileBits"); +test_first_argument(["bits"], 4, "DOMString fileBits"); +test_first_argument(["𝓽𝓮𝔁𝓽"], 16, "Unicode DOMString fileBits"); +test_first_argument([new String('string object')], 13, "String object fileBits"); +test_first_argument([new Blob()], 0, "Empty Blob fileBits"); +test_first_argument([new Blob(["bits"])], 4, "Blob fileBits"); +test_first_argument([new File([], 'world.txt')], 0, "Empty File fileBits"); +test_first_argument([new File(["bits"], 'world.txt')], 4, "File fileBits"); +test_first_argument([new ArrayBuffer(8)], 8, "ArrayBuffer fileBits"); +test_first_argument([new Uint8Array([0x50, 0x41, 0x53, 0x53])], 4, "Typed array fileBits"); +test_first_argument(["bits", new Blob(["bits"]), new Blob(), new Uint8Array([0x50, 0x41]), + new Uint16Array([0x5353]), new Uint32Array([0x53534150])], 16, "Various fileBits"); +test_first_argument([12], 2, "Number in fileBits"); +test_first_argument([[1,2,3]], 5, "Array in fileBits"); +test_first_argument([{}], 15, "Object in fileBits"); // "[object Object]" +if (globalThis.document !== undefined) { + test_first_argument([document.body], 24, "HTMLBodyElement in fileBits"); // "[object HTMLBodyElement]" +} +test_first_argument([to_string_obj], 8, "Object with toString in fileBits"); +test_first_argument({[Symbol.iterator]() { + let i = 0; + return {next: () => [ + {done:false, value:'ab'}, + {done:false, value:'cde'}, + {done:true} + ][i++]}; +}}, 5, 'Custom @@iterator'); + +[ + 'hello', + 0, + null +].forEach(arg => { + test(t => { + assert_throws_js(TypeError, () => new File(arg, 'world.html'), + 'Constructor should throw for invalid bits argument'); + }, `Invalid bits argument: ${JSON.stringify(arg)}`); +}); + +test(t => { + assert_throws_js(Error, () => new File([to_string_throws], 'name.txt'), + 'Constructor should propagate exceptions'); +}, 'Bits argument: object that throws'); + + +function test_second_argument(arg2, expectedFileName, testName) { + test(function() { + var file = new File(["bits"], arg2); + assert_true(file instanceof File); + assert_equals(file.name, expectedFileName); + }, testName); +} + +test_second_argument("dummy", "dummy", "Using fileName"); +test_second_argument("dummy/foo", "dummy/foo", + "No replacement when using special character in fileName"); +test_second_argument(null, "null", "Using null fileName"); +test_second_argument(1, "1", "Using number fileName"); +test_second_argument('', '', "Using empty string fileName"); +if (globalThis.document !== undefined) { + test_second_argument(document.body, '[object HTMLBodyElement]', "Using object fileName"); +} + +// testing the third argument +[ + {type: 'text/plain', expected: 'text/plain'}, + {type: 'text/plain;charset=UTF-8', expected: 'text/plain;charset=utf-8'}, + {type: 'TEXT/PLAIN', expected: 'text/plain'}, + {type: '𝓽𝓮𝔁𝓽/𝔭𝔩𝔞𝔦𝔫', expected: ''}, + {type: 'ascii/nonprintable\u001F', expected: ''}, + {type: 'ascii/nonprintable\u007F', expected: ''}, + {type: 'nonascii\u00EE', expected: ''}, + {type: 'nonascii\u1234', expected: ''}, + {type: 'nonparsable', expected: 'nonparsable'} +].forEach(testCase => { + test(t => { + var file = new File(["bits"], "dummy", { type: testCase.type}); + assert_true(file instanceof File); + assert_equals(file.type, testCase.expected); + }, `Using type in File constructor: ${testCase.type}`); +}); +test(function() { + var file = new File(["bits"], "dummy", { lastModified: 42 }); + assert_true(file instanceof File); + assert_equals(file.lastModified, 42); +}, "Using lastModified"); +test(function() { + var file = new File(["bits"], "dummy", { name: "foo" }); + assert_true(file instanceof File); + assert_equals(file.name, "dummy"); +}, "Misusing name"); +test(function() { + var file = new File(["bits"], "dummy", { unknownKey: "value" }); + assert_true(file instanceof File); + assert_equals(file.name, "dummy"); +}, "Unknown properties are ignored"); + +[ + 123, + 123.4, + true, + 'abc' +].forEach(arg => { + test(t => { + assert_throws_js(TypeError, () => new File(['bits'], 'name.txt', arg), + 'Constructor should throw for invalid property bag type'); + }, `Invalid property bag: ${JSON.stringify(arg)}`); +}); + +[ + null, + undefined, + [1,2,3], + /regex/, + function() {} +].forEach(arg => { + test(t => { + assert_equals(new File(['bits'], 'name.txt', arg).size, 4, + 'Constructor should accept object-ish property bag type'); + }, `Unusual but valid property bag: ${arg}`); +}); + +test(t => { + assert_throws_js(Error, + () => new File(['bits'], 'name.txt', {type: to_string_throws}), + 'Constructor should propagate exceptions'); +}, 'Property bag propagates exceptions'); diff --git a/test/wpt/tests/FileAPI/file/Worker-read-file-constructor.worker.js b/test/wpt/tests/FileAPI/file/Worker-read-file-constructor.worker.js new file mode 100644 index 0000000..4e003b3 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/Worker-read-file-constructor.worker.js @@ -0,0 +1,15 @@ +importScripts("/resources/testharness.js"); + +async_test(function() { + var file = new File(["bits"], "dummy", { 'type': 'text/plain', lastModified: 42 }); + var reader = new FileReader(); + reader.onload = this.step_func_done(function() { + assert_equals(file.name, "dummy", "file name"); + assert_equals(reader.result, "bits", "file content"); + assert_equals(file.lastModified, 42, "file lastModified"); + }); + reader.onerror = this.unreached_func("Unexpected error event"); + reader.readAsText(file); +}, "FileReader in Worker"); + +done(); diff --git a/test/wpt/tests/FileAPI/file/resources/echo-content-escaped.py b/test/wpt/tests/FileAPI/file/resources/echo-content-escaped.py new file mode 100644 index 0000000..5370e1e --- /dev/null +++ b/test/wpt/tests/FileAPI/file/resources/echo-content-escaped.py @@ -0,0 +1,26 @@ +from wptserve.utils import isomorphic_encode + +# Outputs the request body, with controls and non-ASCII bytes escaped +# (b"\n" becomes b"\\x0a"), and with backslashes doubled. +# As a convenience, CRLF newlines are left as is. + +def escape_byte(byte): + # Convert int byte into a single-char binary string. + byte = bytes([byte]) + if b"\0" <= byte <= b"\x1F" or byte >= b"\x7F": + return b"\\x%02x" % ord(byte) + if byte == b"\\": + return b"\\\\" + return byte + +def main(request, response): + + headers = [(b"X-Request-Method", isomorphic_encode(request.method)), + (b"X-Request-Content-Length", request.headers.get(b"Content-Length", b"NO")), + (b"X-Request-Content-Type", request.headers.get(b"Content-Type", b"NO")), + # Avoid any kind of content sniffing on the response. + (b"Content-Type", b"text/plain; charset=UTF-8")] + + content = b"".join(map(escape_byte, request.body)).replace(b"\\x0d\\x0a", b"\r\n") + + return headers, content diff --git a/test/wpt/tests/FileAPI/file/send-file-form-controls.html b/test/wpt/tests/FileAPI/file/send-file-form-controls.html new file mode 100644 index 0000000..6347065 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-controls.html @@ -0,0 +1,113 @@ + + +Upload files named using controls + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form-iso-2022-jp.html b/test/wpt/tests/FileAPI/file/send-file-form-iso-2022-jp.html new file mode 100644 index 0000000..c931c9b --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-iso-2022-jp.html @@ -0,0 +1,65 @@ + + + +Upload files in ISO-2022-JP form + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form-punctuation.html b/test/wpt/tests/FileAPI/file/send-file-form-punctuation.html new file mode 100644 index 0000000..a6568e2 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-punctuation.html @@ -0,0 +1,226 @@ + + +Upload files named using punctuation + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form-utf-8.html b/test/wpt/tests/FileAPI/file/send-file-form-utf-8.html new file mode 100644 index 0000000..1be44f4 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-utf-8.html @@ -0,0 +1,62 @@ + + +Upload files in UTF-8 form + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form-windows-1252.html b/test/wpt/tests/FileAPI/file/send-file-form-windows-1252.html new file mode 100644 index 0000000..21b219f --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-windows-1252.html @@ -0,0 +1,62 @@ + + +Upload files in Windows-1252 form + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form-x-user-defined.html b/test/wpt/tests/FileAPI/file/send-file-form-x-user-defined.html new file mode 100644 index 0000000..8d6605d --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form-x-user-defined.html @@ -0,0 +1,63 @@ + + +Upload files in x-user-defined form + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-form.html b/test/wpt/tests/FileAPI/file/send-file-form.html new file mode 100644 index 0000000..baa8d42 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-form.html @@ -0,0 +1,25 @@ + + +Upload ASCII-named file in UTF-8 form + + + + + + + + diff --git a/test/wpt/tests/FileAPI/file/send-file-formdata-controls.any.js b/test/wpt/tests/FileAPI/file/send-file-formdata-controls.any.js new file mode 100644 index 0000000..e95d3aa --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-formdata-controls.any.js @@ -0,0 +1,69 @@ +// META: title=FormData: FormData: Upload files named using controls +// META: script=../support/send-file-formdata-helper.js + "use strict"; + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-NUL-[\0].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-BS-[\b].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-VT-[\v].txt", + }); + + // These have characters that undergo processing in name=, + // filename=, and/or value; formDataPostFileUploadTest postprocesses + // expectedEncodedBaseName for these internally. + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-LF-[\n].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-LF-CR-[\n\r].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-CR-[\r].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-CR-LF-[\r\n].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-HT-[\t].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-FF-[\f].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-DEL-[\x7F].txt", + }); + + // The rest should be passed through unmodified: + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-ESC-[\x1B].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-SPACE-[ ].txt", + }); diff --git a/test/wpt/tests/FileAPI/file/send-file-formdata-punctuation.any.js b/test/wpt/tests/FileAPI/file/send-file-formdata-punctuation.any.js new file mode 100644 index 0000000..987dba3 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-formdata-punctuation.any.js @@ -0,0 +1,144 @@ +// META: title=FormData: FormData: Upload files named using punctuation +// META: script=../support/send-file-formdata-helper.js + "use strict"; + + // These have characters that undergo processing in name=, + // filename=, and/or value; formDataPostFileUploadTest postprocesses + // expectedEncodedBaseName for these internally. + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-QUOTATION-MARK-[\x22].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: '"file-for-upload-in-form-double-quoted.txt"', + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-REVERSE-SOLIDUS-[\\].txt", + }); + + // The rest should be passed through unmodified: + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-EXCLAMATION-MARK-[!].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-DOLLAR-SIGN-[$].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-PERCENT-SIGN-[%].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-AMPERSAND-[&].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-APOSTROPHE-['].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-LEFT-PARENTHESIS-[(].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-RIGHT-PARENTHESIS-[)].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-ASTERISK-[*].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-PLUS-SIGN-[+].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-COMMA-[,].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-FULL-STOP-[.].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-SOLIDUS-[/].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-COLON-[:].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-SEMICOLON-[;].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-EQUALS-SIGN-[=].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-QUESTION-MARK-[?].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-CIRCUMFLEX-ACCENT-[^].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-LEFT-SQUARE-BRACKET-[[].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-RIGHT-SQUARE-BRACKET-[]].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-LEFT-CURLY-BRACKET-[{].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-VERTICAL-LINE-[|].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-RIGHT-CURLY-BRACKET-[}].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form-TILDE-[~].txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "'file-for-upload-in-form-single-quoted.txt'", + }); diff --git a/test/wpt/tests/FileAPI/file/send-file-formdata-utf-8.any.js b/test/wpt/tests/FileAPI/file/send-file-formdata-utf-8.any.js new file mode 100644 index 0000000..b8bd74c --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-formdata-utf-8.any.js @@ -0,0 +1,33 @@ +// META: title=FormData: FormData: Upload files in UTF-8 fetch() +// META: script=../support/send-file-formdata-helper.js + "use strict"; + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form.txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "x-user-defined", + fileBaseName: "file-for-upload-in-form-\uF7F0\uF793\uF783\uF7A0.txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "windows-1252", + fileBaseName: "file-for-upload-in-form-☺😂.txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "JIS X 0201 and JIS X 0208", + fileBaseName: "file-for-upload-in-form-★星★.txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "Unicode", + fileBaseName: "file-for-upload-in-form-☺😂.txt", + }); + + formDataPostFileUploadTest({ + fileNameSource: "Unicode", + fileBaseName: `file-for-upload-in-form-${kTestChars}.txt`, + }); diff --git a/test/wpt/tests/FileAPI/file/send-file-formdata.any.js b/test/wpt/tests/FileAPI/file/send-file-formdata.any.js new file mode 100644 index 0000000..e13a348 --- /dev/null +++ b/test/wpt/tests/FileAPI/file/send-file-formdata.any.js @@ -0,0 +1,8 @@ +// META: title=FormData: Upload ASCII-named file in UTF-8 form +// META: script=../support/send-file-formdata-helper.js + "use strict"; + + formDataPostFileUploadTest({ + fileNameSource: "ASCII", + fileBaseName: "file-for-upload-in-form.txt", + }); diff --git a/test/wpt/tests/FileAPI/fileReader.any.js b/test/wpt/tests/FileAPI/fileReader.any.js new file mode 100644 index 0000000..2876dcb --- /dev/null +++ b/test/wpt/tests/FileAPI/fileReader.any.js @@ -0,0 +1,59 @@ +// META: title=FileReader States + +'use strict'; + +test(function () { + assert_true( + "FileReader" in globalThis, + "globalThis should have a FileReader property.", + ); +}, "FileReader interface object"); + +test(function () { + var fileReader = new FileReader(); + assert_true(fileReader instanceof FileReader); +}, "no-argument FileReader constructor"); + +var t_abort = async_test("FileReader States -- abort"); +t_abort.step(function () { + var fileReader = new FileReader(); + assert_equals(fileReader.readyState, 0); + assert_equals(fileReader.readyState, FileReader.EMPTY); + + var blob = new Blob(); + fileReader.readAsArrayBuffer(blob); + assert_equals(fileReader.readyState, 1); + assert_equals(fileReader.readyState, FileReader.LOADING); + + fileReader.onabort = this.step_func(function (e) { + assert_equals(fileReader.readyState, 2); + assert_equals(fileReader.readyState, FileReader.DONE); + t_abort.done(); + }); + fileReader.abort(); + fileReader.onabort = this.unreached_func("abort event should fire sync"); +}); + +var t_event = async_test("FileReader States -- events"); +t_event.step(function () { + var fileReader = new FileReader(); + + var blob = new Blob(); + fileReader.readAsArrayBuffer(blob); + + fileReader.onloadstart = this.step_func(function (e) { + assert_equals(fileReader.readyState, 1); + assert_equals(fileReader.readyState, FileReader.LOADING); + }); + + fileReader.onprogress = this.step_func(function (e) { + assert_equals(fileReader.readyState, 1); + assert_equals(fileReader.readyState, FileReader.LOADING); + }); + + fileReader.onloadend = this.step_func(function (e) { + assert_equals(fileReader.readyState, 2); + assert_equals(fileReader.readyState, FileReader.DONE); + t_event.done(); + }); +}); diff --git a/test/wpt/tests/FileAPI/filelist-section/filelist.html b/test/wpt/tests/FileAPI/filelist-section/filelist.html new file mode 100644 index 0000000..b97dcde --- /dev/null +++ b/test/wpt/tests/FileAPI/filelist-section/filelist.html @@ -0,0 +1,57 @@ + + + + + FileAPI Test: filelist + + + + + + + + + +
+ +
+
+ + + + + diff --git a/test/wpt/tests/FileAPI/filelist-section/filelist_multiple_selected_files-manual.html b/test/wpt/tests/FileAPI/filelist-section/filelist_multiple_selected_files-manual.html new file mode 100644 index 0000000..2efaa05 --- /dev/null +++ b/test/wpt/tests/FileAPI/filelist-section/filelist_multiple_selected_files-manual.html @@ -0,0 +1,64 @@ + + + + + FileAPI Test: filelist_multiple_selected_files + + + + + + + + + +
+ +
+
+

Test steps:

+
    +
  1. Download upload.txt, upload.zip to local.
  2. +
  3. Select the local two files (upload.txt, upload.zip) to run the test.
  4. +
+
+ +
+ + + + diff --git a/test/wpt/tests/FileAPI/filelist-section/filelist_selected_file-manual.html b/test/wpt/tests/FileAPI/filelist-section/filelist_selected_file-manual.html new file mode 100644 index 0000000..966aadd --- /dev/null +++ b/test/wpt/tests/FileAPI/filelist-section/filelist_selected_file-manual.html @@ -0,0 +1,64 @@ + + + + + FileAPI Test: filelist_selected_file + + + + + + + + + +
+ +
+
+

Test steps:

+
    +
  1. Download upload.txt to local.
  2. +
  3. Select the local upload.txt file to run the test.
  4. +
+
+ +
+ + + + diff --git a/test/wpt/tests/FileAPI/filelist-section/support/upload.txt b/test/wpt/tests/FileAPI/filelist-section/support/upload.txt new file mode 100644 index 0000000..f45965b --- /dev/null +++ b/test/wpt/tests/FileAPI/filelist-section/support/upload.txt @@ -0,0 +1 @@ +Hello, this is test file for file upload. diff --git a/test/wpt/tests/FileAPI/filelist-section/support/upload.zip b/test/wpt/tests/FileAPI/filelist-section/support/upload.zip new file mode 100644 index 0000000000000000000000000000000000000000..a933d6a949428cc52dc51687c126af4ba66cc084 GIT binary patch literal 220 zcmWIWW@Zs#U|`^2u&Md)bl|2>v<8r;4aEEmG7O~!Ir)hxX_+~xMtUU`C7~gl49t)B z{s?>m#HAJ742&!C$r#AlDQCr@4v)Hr!UCyZgyq$`hvTDP2;6QbO@<&Tnq_rfg- z>h^}N{oD)z-i%Cg%($$S09wqzzzD=!8bK@!2e3jMfM$7sH!B-RIU^8;0_j2!hXDZ7 C6g&_B literal 0 HcmV?d00001 diff --git a/test/wpt/tests/FileAPI/historical.https.html b/test/wpt/tests/FileAPI/historical.https.html new file mode 100644 index 0000000..4f841f1 --- /dev/null +++ b/test/wpt/tests/FileAPI/historical.https.html @@ -0,0 +1,65 @@ + + + + + Historical features + + + + + +
+ + + diff --git a/test/wpt/tests/FileAPI/idlharness-manual.html b/test/wpt/tests/FileAPI/idlharness-manual.html new file mode 100644 index 0000000..c1d8b0c --- /dev/null +++ b/test/wpt/tests/FileAPI/idlharness-manual.html @@ -0,0 +1,45 @@ + + + + + File API manual IDL tests + + + + + + + + +

File API manual IDL tests

+ +

Either download upload.txt and select it below or select an + arbitrary local file.

+ +
+ +
+ +
+ + + + diff --git a/test/wpt/tests/FileAPI/idlharness.any.js b/test/wpt/tests/FileAPI/idlharness.any.js new file mode 100644 index 0000000..1744242 --- /dev/null +++ b/test/wpt/tests/FileAPI/idlharness.any.js @@ -0,0 +1,19 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: timeout=long + +'use strict'; + +// https://w3c.github.io/FileAPI/ + +idl_test( + ['FileAPI'], + ['dom', 'html', 'url'], + idl_array => { + idl_array.add_objects({ + Blob: ['new Blob(["TEST"])'], + File: ['new File(["myFileBits"], "myFileName")'], + FileReader: ['new FileReader()'] + }); + } +); diff --git a/test/wpt/tests/FileAPI/idlharness.html b/test/wpt/tests/FileAPI/idlharness.html new file mode 100644 index 0000000..45e8684 --- /dev/null +++ b/test/wpt/tests/FileAPI/idlharness.html @@ -0,0 +1,37 @@ + + + + + File API automated IDL tests (requiring dom) + + + + + + + + +

File API automated IDL tests

+ +
+ +
+ +
+ + + + + diff --git a/test/wpt/tests/FileAPI/idlharness.worker.js b/test/wpt/tests/FileAPI/idlharness.worker.js new file mode 100644 index 0000000..002aaed --- /dev/null +++ b/test/wpt/tests/FileAPI/idlharness.worker.js @@ -0,0 +1,17 @@ +importScripts("/resources/testharness.js"); +importScripts("/resources/WebIDLParser.js", "/resources/idlharness.js"); + +'use strict'; + +// https://w3c.github.io/FileAPI/ + +idl_test( + ['FileAPI'], + ['dom', 'html', 'url'], + idl_array => { + idl_array.add_objects({ + FileReaderSync: ['new FileReaderSync()'] + }); + } +); +done(); diff --git a/test/wpt/tests/FileAPI/progress-manual.html b/test/wpt/tests/FileAPI/progress-manual.html new file mode 100644 index 0000000..b2e03b3 --- /dev/null +++ b/test/wpt/tests/FileAPI/progress-manual.html @@ -0,0 +1,49 @@ + + +Process Events for FileReader + + + + +Please choose one file through this input below.
+ +
+ diff --git a/test/wpt/tests/FileAPI/reading-data-section/Determining-Encoding.any.js b/test/wpt/tests/FileAPI/reading-data-section/Determining-Encoding.any.js new file mode 100644 index 0000000..5b69f7e --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/Determining-Encoding.any.js @@ -0,0 +1,81 @@ +// META: title=FileAPI Test: Blob Determining Encoding + +var t = async_test("Blob Determing Encoding with encoding argument"); +t.step(function() { + // string 'hello' + var data = [0xFE,0xFF,0x00,0x68,0x00,0x65,0x00,0x6C,0x00,0x6C,0x00,0x6F]; + var blob = new Blob([new Uint8Array(data)]); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hello", "The FileReader should read the ArrayBuffer through UTF-16BE.") + }, reader); + + reader.readAsText(blob, "UTF-16BE"); +}); + +var t = async_test("Blob Determing Encoding with type attribute"); +t.step(function() { + var data = [0xFE,0xFF,0x00,0x68,0x00,0x65,0x00,0x6C,0x00,0x6C,0x00,0x6F]; + var blob = new Blob([new Uint8Array(data)], {type:"text/plain;charset=UTF-16BE"}); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hello", "The FileReader should read the ArrayBuffer through UTF-16BE.") + }, reader); + + reader.readAsText(blob); +}); + + +var t = async_test("Blob Determing Encoding with UTF-8 BOM"); +t.step(function() { + var data = [0xEF,0xBB,0xBF,0x68,0x65,0x6C,0x6C,0xC3,0xB6]; + var blob = new Blob([new Uint8Array(data)]); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hellö", "The FileReader should read the blob with UTF-8."); + }, reader); + + reader.readAsText(blob); +}); + +var t = async_test("Blob Determing Encoding without anything implying charset."); +t.step(function() { + var data = [0x68,0x65,0x6C,0x6C,0xC3,0xB6]; + var blob = new Blob([new Uint8Array(data)]); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hellö", "The FileReader should read the blob by default with UTF-8."); + }, reader); + + reader.readAsText(blob); +}); + +var t = async_test("Blob Determing Encoding with UTF-16BE BOM"); +t.step(function() { + var data = [0xFE,0xFF,0x00,0x68,0x00,0x65,0x00,0x6C,0x00,0x6C,0x00,0x6F]; + var blob = new Blob([new Uint8Array(data)]); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hello", "The FileReader should read the ArrayBuffer through UTF-16BE."); + }, reader); + + reader.readAsText(blob); +}); + +var t = async_test("Blob Determing Encoding with UTF-16LE BOM"); +t.step(function() { + var data = [0xFF,0xFE,0x68,0x00,0x65,0x00,0x6C,0x00,0x6C,0x00,0x6F,0x00]; + var blob = new Blob([new Uint8Array(data)]); + var reader = new FileReader(); + + reader.onloadend = t.step_func_done (function(event) { + assert_equals(this.result, "hello", "The FileReader should read the ArrayBuffer through UTF-16LE."); + }, reader); + + reader.readAsText(blob); +}); diff --git a/test/wpt/tests/FileAPI/reading-data-section/FileReader-event-handler-attributes.any.js b/test/wpt/tests/FileAPI/reading-data-section/FileReader-event-handler-attributes.any.js new file mode 100644 index 0000000..fc71c64 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/FileReader-event-handler-attributes.any.js @@ -0,0 +1,17 @@ +// META: title=FileReader event handler attributes + +var attributes = [ + "onloadstart", + "onprogress", + "onload", + "onabort", + "onerror", + "onloadend", +]; +attributes.forEach(function(a) { + test(function() { + var reader = new FileReader(); + assert_equals(reader[a], null, + "event handler attribute should initially be null"); + }, "FileReader." + a + ": initial value"); +}); diff --git a/test/wpt/tests/FileAPI/reading-data-section/FileReader-multiple-reads.any.js b/test/wpt/tests/FileAPI/reading-data-section/FileReader-multiple-reads.any.js new file mode 100644 index 0000000..4b19c69 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/FileReader-multiple-reads.any.js @@ -0,0 +1,81 @@ +// META: title=FileReader: starting new reads while one is in progress + +test(function() { + var blob_1 = new Blob(['TEST000000001']) + var blob_2 = new Blob(['TEST000000002']) + var reader = new FileReader(); + reader.readAsText(blob_1) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") + assert_throws_dom("InvalidStateError", function () { + reader.readAsText(blob_2) + }) +}, 'test FileReader InvalidStateError exception for readAsText'); + +test(function() { + var blob_1 = new Blob(['TEST000000001']) + var blob_2 = new Blob(['TEST000000002']) + var reader = new FileReader(); + reader.readAsDataURL(blob_1) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") + assert_throws_dom("InvalidStateError", function () { + reader.readAsDataURL(blob_2) + }) +}, 'test FileReader InvalidStateError exception for readAsDataURL'); + +test(function() { + var blob_1 = new Blob(['TEST000000001']) + var blob_2 = new Blob(['TEST000000002']) + var reader = new FileReader(); + reader.readAsArrayBuffer(blob_1) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") + assert_throws_dom("InvalidStateError", function () { + reader.readAsArrayBuffer(blob_2) + }) +}, 'test FileReader InvalidStateError exception for readAsArrayBuffer'); + +async_test(function() { + var blob_1 = new Blob(['TEST000000001']) + var blob_2 = new Blob(['TEST000000002']) + var reader = new FileReader(); + var triggered = false; + reader.onloadstart = this.step_func_done(function() { + assert_false(triggered, "Only one loadstart event should be dispatched"); + triggered = true; + assert_equals(reader.readyState, FileReader.LOADING, + "readyState must be LOADING") + assert_throws_dom("InvalidStateError", function () { + reader.readAsArrayBuffer(blob_2) + }) + }); + reader.readAsArrayBuffer(blob_1) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") +}, 'test FileReader InvalidStateError exception in onloadstart event for readAsArrayBuffer'); + +async_test(function() { + var blob_1 = new Blob(['TEST000000001']) + var blob_2 = new Blob(['TEST000000002']) + var reader = new FileReader(); + reader.onloadend = this.step_func_done(function() { + assert_equals(reader.readyState, FileReader.DONE, + "readyState must be DONE") + reader.readAsArrayBuffer(blob_2) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") + }); + reader.readAsArrayBuffer(blob_1) + assert_equals(reader.readyState, FileReader.LOADING, "readyState Must be LOADING") +}, 'test FileReader no InvalidStateError exception in loadend event handler for readAsArrayBuffer'); + +async_test(function() { + var blob_1 = new Blob([new Uint8Array(0x414141)]); + var blob_2 = new Blob(['TEST000000002']); + var reader = new FileReader(); + reader.onloadstart = this.step_func(function() { + reader.abort(); + reader.onloadstart = null; + reader.onloadend = this.step_func_done(function() { + assert_equals('TEST000000002', reader.result); + }); + reader.readAsText(blob_2); + }); + reader.readAsText(blob_1); +}, 'test abort and restart in onloadstart event for readAsText'); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_abort.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_abort.any.js new file mode 100644 index 0000000..c778ae5 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_abort.any.js @@ -0,0 +1,38 @@ +// META: title=FileAPI Test: filereader_abort + + test(function() { + var readerNoRead = new FileReader(); + readerNoRead.abort(); + assert_equals(readerNoRead.readyState, readerNoRead.EMPTY); + assert_equals(readerNoRead.result, null); + }, "Aborting before read"); + + promise_test(t => { + var blob = new Blob(["TEST THE ABORT METHOD"]); + var readerAbort = new FileReader(); + + var eventWatcher = new EventWatcher(t, readerAbort, + ['abort', 'loadstart', 'loadend', 'error', 'load']); + + // EventWatcher doesn't let us inspect the state after the abort event, + // so add an extra event handler for that. + readerAbort.addEventListener('abort', t.step_func(e => { + assert_equals(readerAbort.readyState, readerAbort.DONE); + })); + + readerAbort.readAsText(blob); + return eventWatcher.wait_for('loadstart') + .then(() => { + assert_equals(readerAbort.readyState, readerAbort.LOADING); + // 'abort' and 'loadend' events are dispatched synchronously, so + // call wait_for before calling abort. + var nextEvent = eventWatcher.wait_for(['abort', 'loadend']); + readerAbort.abort(); + return nextEvent; + }) + .then(() => { + // https://www.w3.org/Bugs/Public/show_bug.cgi?id=24401 + assert_equals(readerAbort.result, null); + assert_equals(readerAbort.readyState, readerAbort.DONE); + }); + }, "Aborting after read"); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_error.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_error.any.js new file mode 100644 index 0000000..9845962 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_error.any.js @@ -0,0 +1,19 @@ +// META: title=FileAPI Test: filereader_error + + async_test(function() { + var blob = new Blob(["TEST THE ERROR ATTRIBUTE AND ERROR EVENT"]); + var reader = new FileReader(); + assert_equals(reader.error, null, "The error is null when no error occurred"); + + reader.onload = this.step_func(function(evt) { + assert_unreached("Should not dispatch the load event"); + }); + + reader.onloadend = this.step_func(function(evt) { + assert_equals(reader.result, null, "The result is null"); + this.done(); + }); + + reader.readAsText(blob); + reader.abort(); + }); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_events.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_events.any.js new file mode 100644 index 0000000..ac69290 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_events.any.js @@ -0,0 +1,19 @@ +promise_test(async t => { + var reader = new FileReader(); + var eventWatcher = new EventWatcher(t, reader, ['loadstart', 'progress', 'abort', 'error', 'load', 'loadend']); + reader.readAsText(new Blob([])); + await eventWatcher.wait_for('loadstart'); + // No progress event for an empty blob, as no data is loaded. + await eventWatcher.wait_for('load'); + await eventWatcher.wait_for('loadend'); +}, 'events are dispatched in the correct order for an empty blob'); + +promise_test(async t => { + var reader = new FileReader(); + var eventWatcher = new EventWatcher(t, reader, ['loadstart', 'progress', 'abort', 'error', 'load', 'loadend']); + reader.readAsText(new Blob(['a'])); + await eventWatcher.wait_for('loadstart'); + await eventWatcher.wait_for('progress'); + await eventWatcher.wait_for('load'); + await eventWatcher.wait_for('loadend'); +}, 'events are dispatched in the correct order for a non-empty blob'); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_file-manual.html b/test/wpt/tests/FileAPI/reading-data-section/filereader_file-manual.html new file mode 100644 index 0000000..702ca9a --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_file-manual.html @@ -0,0 +1,69 @@ + + + + + FileAPI Test: filereader_file + + + + + + + +
+

Test step:

+
    +
  1. Download blue-100x100.png to local.
  2. +
  3. Select the local file (blue-100x100.png) to run the test.
  4. +
+
+ +
+ +
+ +
+ + + diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_file_img-manual.html b/test/wpt/tests/FileAPI/reading-data-section/filereader_file_img-manual.html new file mode 100644 index 0000000..fca42c7 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_file_img-manual.html @@ -0,0 +1,47 @@ + + + + + FileAPI Test: filereader_file_img + + + + + + + +
+

Test step:

+
    +
  1. Download blue-100x100.png to local.
  2. +
  3. Select the local file (blue-100x100.png) to run the test.
  4. +
+
+ +
+ +
+ +
+ + + diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsArrayBuffer.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsArrayBuffer.any.js new file mode 100644 index 0000000..d06e317 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsArrayBuffer.any.js @@ -0,0 +1,23 @@ +// META: title=FileAPI Test: filereader_readAsArrayBuffer + + async_test(function() { + var blob = new Blob(["TEST"]); + var reader = new FileReader(); + + reader.onload = this.step_func(function(evt) { + assert_equals(reader.result.byteLength, 4, "The byteLength is 4"); + assert_true(reader.result instanceof ArrayBuffer, "The result is instanceof ArrayBuffer"); + assert_equals(reader.readyState, reader.DONE); + this.done(); + }); + + reader.onloadstart = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.onprogress = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.readAsArrayBuffer(blob); + }); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsBinaryString.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsBinaryString.any.js new file mode 100644 index 0000000..e69ff15 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsBinaryString.any.js @@ -0,0 +1,23 @@ +// META: title=FileAPI Test: filereader_readAsBinaryString + +async_test(t => { + const blob = new Blob(["σ"]); + const reader = new FileReader(); + + reader.onload = t.step_func_done(() => { + assert_equals(typeof reader.result, "string", "The result is string"); + assert_equals(reader.result.length, 2, "The result length is 2"); + assert_equals(reader.result, "\xcf\x83", "The result is \xcf\x83"); + assert_equals(reader.readyState, reader.DONE); + }); + + reader.onloadstart = t.step_func(() => { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.onprogress = t.step_func(() => { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.readAsBinaryString(blob); +}); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsDataURL.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsDataURL.any.js new file mode 100644 index 0000000..4f9dbf7 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsDataURL.any.js @@ -0,0 +1,54 @@ +// META: title=FileAPI Test: FileReader.readAsDataURL + +async_test(function(testCase) { + var blob = new Blob(["TEST"]); + var reader = new FileReader(); + + reader.onload = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.DONE); + testCase.done(); + }); + reader.onloadstart = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + reader.onprogress = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.readAsDataURL(blob); +}, 'FileReader readyState during readAsDataURL'); + +async_test(function(testCase) { + var blob = new Blob(["TEST"], { type: 'text/plain' }); + var reader = new FileReader(); + + reader.onload = this.step_func(function() { + assert_equals(reader.result, "data:text/plain;base64,VEVTVA=="); + testCase.done(); + }); + reader.readAsDataURL(blob); +}, 'readAsDataURL result for Blob with specified MIME type'); + +async_test(function(testCase) { + var blob = new Blob(["TEST"]); + var reader = new FileReader(); + + reader.onload = this.step_func(function() { + assert_equals(reader.result, + "data:application/octet-stream;base64,VEVTVA=="); + testCase.done(); + }); + reader.readAsDataURL(blob); +}, 'readAsDataURL result for Blob with unspecified MIME type'); + +async_test(function(testCase) { + var blob = new Blob([]); + var reader = new FileReader(); + + reader.onload = this.step_func(function() { + assert_equals(reader.result, + "data:application/octet-stream;base64,"); + testCase.done(); + }); + reader.readAsDataURL(blob); +}, 'readAsDataURL result for empty Blob'); \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsText.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsText.any.js new file mode 100644 index 0000000..4d0fa11 --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_readAsText.any.js @@ -0,0 +1,36 @@ +// META: title=FileAPI Test: filereader_readAsText + + async_test(function() { + var blob = new Blob(["TEST"]); + var reader = new FileReader(); + + reader.onload = this.step_func(function(evt) { + assert_equals(typeof reader.result, "string", "The result is typeof string"); + assert_equals(reader.result, "TEST", "The result is TEST"); + this.done(); + }); + + reader.onloadstart = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING, "The readyState"); + }); + + reader.onprogress = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.readAsText(blob); + }, "readAsText should correctly read UTF-8."); + + async_test(function() { + var blob = new Blob(["TEST"]); + var reader = new FileReader(); + var reader_UTF16 = new FileReader(); + reader_UTF16.onload = this.step_func(function(evt) { + // "TEST" in UTF-8 is 0x54 0x45 0x53 0x54. + // Decoded as utf-16 (little-endian), we get 0x4554 0x5453. + assert_equals(reader_UTF16.readyState, reader.DONE, "The readyState"); + assert_equals(reader_UTF16.result, "\u4554\u5453", "The result is not TEST"); + this.done(); + }); + reader_UTF16.readAsText(blob, "UTF-16"); + }, "readAsText should correctly read UTF-16."); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_readystate.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_readystate.any.js new file mode 100644 index 0000000..3cb36ab --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_readystate.any.js @@ -0,0 +1,19 @@ +// META: title=FileAPI Test: filereader_readystate + + async_test(function() { + var blob = new Blob(["THIS TEST THE READYSTATE WHEN READ BLOB"]); + var reader = new FileReader(); + + assert_equals(reader.readyState, reader.EMPTY); + + reader.onloadstart = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.LOADING); + }); + + reader.onloadend = this.step_func(function(evt) { + assert_equals(reader.readyState, reader.DONE); + this.done(); + }); + + reader.readAsDataURL(blob); + }); diff --git a/test/wpt/tests/FileAPI/reading-data-section/filereader_result.any.js b/test/wpt/tests/FileAPI/reading-data-section/filereader_result.any.js new file mode 100644 index 0000000..28c068b --- /dev/null +++ b/test/wpt/tests/FileAPI/reading-data-section/filereader_result.any.js @@ -0,0 +1,82 @@ +// META: title=FileAPI Test: filereader_result + + var blob, blob2; + setup(function() { + blob = new Blob(["This test the result attribute"]); + blob2 = new Blob(["This is a second blob"]); + }); + + async_test(function() { + var readText = new FileReader(); + assert_equals(readText.result, null); + + readText.onloadend = this.step_func(function(evt) { + assert_equals(typeof readText.result, "string", "The result type is string"); + assert_equals(readText.result, "This test the result attribute", "The result is correct"); + this.done(); + }); + + readText.readAsText(blob); + }, "readAsText"); + + async_test(function() { + var readDataURL = new FileReader(); + assert_equals(readDataURL.result, null); + + readDataURL.onloadend = this.step_func(function(evt) { + assert_equals(typeof readDataURL.result, "string", "The result type is string"); + assert_true(readDataURL.result.indexOf("VGhpcyB0ZXN0IHRoZSByZXN1bHQgYXR0cmlidXRl") != -1, "return the right base64 string"); + this.done(); + }); + + readDataURL.readAsDataURL(blob); + }, "readAsDataURL"); + + async_test(function() { + var readArrayBuffer = new FileReader(); + assert_equals(readArrayBuffer.result, null); + + readArrayBuffer.onloadend = this.step_func(function(evt) { + assert_true(readArrayBuffer.result instanceof ArrayBuffer, "The result is instanceof ArrayBuffer"); + this.done(); + }); + + readArrayBuffer.readAsArrayBuffer(blob); + }, "readAsArrayBuffer"); + + async_test(function() { + var readBinaryString = new FileReader(); + assert_equals(readBinaryString.result, null); + + readBinaryString.onloadend = this.step_func(function(evt) { + assert_equals(typeof readBinaryString.result, "string", "The result type is string"); + assert_equals(readBinaryString.result, "This test the result attribute", "The result is correct"); + this.done(); + }); + + readBinaryString.readAsBinaryString(blob); + }, "readAsBinaryString"); + + + for (let event of ['loadstart', 'progress']) { + for (let method of ['readAsText', 'readAsDataURL', 'readAsArrayBuffer', 'readAsBinaryString']) { + promise_test(async function(t) { + var reader = new FileReader(); + assert_equals(reader.result, null, 'result is null before read'); + + var eventWatcher = new EventWatcher(t, reader, + [event, 'loadend']); + + reader[method](blob); + assert_equals(reader.result, null, 'result is null after first read call'); + await eventWatcher.wait_for(event); + assert_equals(reader.result, null, 'result is null during event'); + await eventWatcher.wait_for('loadend'); + assert_not_equals(reader.result, null); + reader[method](blob); + assert_equals(reader.result, null, 'result is null after second read call'); + await eventWatcher.wait_for(event); + assert_equals(reader.result, null, 'result is null during second read event'); + }, 'result is null during "' + event + '" event for ' + method); + } + } diff --git a/test/wpt/tests/FileAPI/reading-data-section/support/blue-100x100.png b/test/wpt/tests/FileAPI/reading-data-section/support/blue-100x100.png new file mode 100644 index 0000000000000000000000000000000000000000..5748719ff22a34993eeb4de4d1fe7cfd27e92959 GIT binary patch literal 227 zcmeAS@N?(olHy`uVBq!ia0vp^DIm~)y|_`3!GMF=@cn+{ zM(Z>W$Ayn<8>TLantrR&N?1UFiOI#GfrCSUk?|1=5=V*VY==1v5S#zzF*vSX*L$9$ Q1mqnCPgg&ebxsLQ0LwxylmGw# literal 0 HcmV?d00001 diff --git a/test/wpt/tests/FileAPI/support/Blob.js b/test/wpt/tests/FileAPI/support/Blob.js new file mode 100644 index 0000000..2c24974 --- /dev/null +++ b/test/wpt/tests/FileAPI/support/Blob.js @@ -0,0 +1,70 @@ +'use strict' + +self.test_blob = (fn, expectations) => { + var expected = expectations.expected, + type = expectations.type, + desc = expectations.desc; + + var t = async_test(desc); + t.step(function() { + var blob = fn(); + assert_true(blob instanceof Blob); + assert_false(blob instanceof File); + assert_equals(blob.type, type); + assert_equals(blob.size, expected.length); + + var fr = new FileReader(); + fr.onload = t.step_func_done(function(event) { + assert_equals(this.result, expected); + }, fr); + fr.onerror = t.step_func(function(e) { + assert_unreached("got error event on FileReader"); + }); + fr.readAsText(blob, "UTF-8"); + }); +} + +self.test_blob_binary = (fn, expectations) => { + var expected = expectations.expected, + type = expectations.type, + desc = expectations.desc; + + var t = async_test(desc); + t.step(function() { + var blob = fn(); + assert_true(blob instanceof Blob); + assert_false(blob instanceof File); + assert_equals(blob.type, type); + assert_equals(blob.size, expected.length); + + var fr = new FileReader(); + fr.onload = t.step_func_done(function(event) { + assert_true(this.result instanceof ArrayBuffer, + "Result should be an ArrayBuffer"); + assert_array_equals(new Uint8Array(this.result), expected); + }, fr); + fr.onerror = t.step_func(function(e) { + assert_unreached("got error event on FileReader"); + }); + fr.readAsArrayBuffer(blob); + }); +} + +// Assert that two TypedArray objects have the same byte values +self.assert_equals_typed_array = (array1, array2) => { + const [view1, view2] = [array1, array2].map((array) => { + assert_true(array.buffer instanceof ArrayBuffer, + 'Expect input ArrayBuffers to contain field `buffer`'); + return new DataView(array.buffer, array.byteOffset, array.byteLength); + }); + + assert_equals(view1.byteLength, view2.byteLength, + 'Expect both arrays to be of the same byte length'); + + const byteLength = view1.byteLength; + + for (let i = 0; i < byteLength; ++i) { + assert_equals(view1.getUint8(i), view2.getUint8(i), + `Expect byte at buffer position ${i} to be equal`); + } +} diff --git a/test/wpt/tests/FileAPI/support/document-domain-setter.sub.html b/test/wpt/tests/FileAPI/support/document-domain-setter.sub.html new file mode 100644 index 0000000..61aebdf --- /dev/null +++ b/test/wpt/tests/FileAPI/support/document-domain-setter.sub.html @@ -0,0 +1,7 @@ + +Relevant/current/blob source page used as a test helper + + diff --git a/test/wpt/tests/FileAPI/support/empty-document.html b/test/wpt/tests/FileAPI/support/empty-document.html new file mode 100644 index 0000000..b9cd130 --- /dev/null +++ b/test/wpt/tests/FileAPI/support/empty-document.html @@ -0,0 +1,3 @@ + + + diff --git a/test/wpt/tests/FileAPI/support/historical-serviceworker.js b/test/wpt/tests/FileAPI/support/historical-serviceworker.js new file mode 100644 index 0000000..8bd89a2 --- /dev/null +++ b/test/wpt/tests/FileAPI/support/historical-serviceworker.js @@ -0,0 +1,5 @@ +importScripts('/resources/testharness.js'); + +test(() => { + assert_false('FileReaderSync' in self); +}, '"FileReaderSync" should not be supported in service workers'); diff --git a/test/wpt/tests/FileAPI/support/incumbent.sub.html b/test/wpt/tests/FileAPI/support/incumbent.sub.html new file mode 100644 index 0000000..63a81cd --- /dev/null +++ b/test/wpt/tests/FileAPI/support/incumbent.sub.html @@ -0,0 +1,22 @@ + +Incumbent page used as a test helper + + + + + + diff --git a/test/wpt/tests/FileAPI/support/send-file-form-helper.js b/test/wpt/tests/FileAPI/support/send-file-form-helper.js new file mode 100644 index 0000000..d6adf21 --- /dev/null +++ b/test/wpt/tests/FileAPI/support/send-file-form-helper.js @@ -0,0 +1,282 @@ +'use strict'; + +// See /FileAPI/file/resources/echo-content-escaped.py +function escapeString(string) { + return string.replace(/\\/g, "\\\\").replace( + /[^\x20-\x7E]/g, + (x) => { + let hex = x.charCodeAt(0).toString(16); + if (hex.length < 2) hex = "0" + hex; + return `\\x${hex}`; + }, + ).replace(/\\x0d\\x0a/g, "\r\n"); +} + +// Rationale for this particular test character sequence, which is +// used in filenames and also in file contents: +// +// - ABC~ ensures the string starts with something we can read to +// ensure it is from the correct source; ~ is used because even +// some 1-byte otherwise-ASCII-like parts of ISO-2022-JP +// interpret it differently. +// - ‾¥ are inside a single-byte range of ISO-2022-JP and help +// diagnose problems due to filesystem encoding or locale +// - ≈ is inside IBM437 and helps diagnose problems due to filesystem +// encoding or locale +// - ¤ is inside Latin-1 and helps diagnose problems due to +// filesystem encoding or locale; it is also the "simplest" case +// needing substitution in ISO-2022-JP +// - ï½¥ is inside a single-byte range of ISO-2022-JP in some variants +// and helps diagnose problems due to filesystem encoding or locale; +// on the web it is distinct when decoding but unified when encoding +// - ・ is inside a double-byte range of ISO-2022-JP and helps +// diagnose problems due to filesystem encoding or locale +// - • is inside Windows-1252 and helps diagnose problems due to +// filesystem encoding or locale and also ensures these aren't +// accidentally turned into e.g. control codes +// - ∙ is inside IBM437 and helps diagnose problems due to filesystem +// encoding or locale +// - · is inside Latin-1 and helps diagnose problems due to +// filesystem encoding or locale and also ensures HTML named +// character references (e.g. ·) are not used +// - ☼ is inside IBM437 shadowing C0 and helps diagnose problems due to +// filesystem encoding or locale and also ensures these aren't +// accidentally turned into e.g. control codes +// - ★ is inside ISO-2022-JP on a non-Kanji page and makes correct +// output easier to spot +// - 星 is inside ISO-2022-JP on a Kanji page and makes correct +// output easier to spot +// - 🌟 is outside the BMP and makes incorrect surrogate pair +// substitution detectable and ensures substitutions work +// correctly immediately after Kanji 2-byte ISO-2022-JP +// - 星 repeated here ensures the correct codec state is used +// after a non-BMP substitution +// - ★ repeated here also makes correct output easier to spot +// - ☼ is inside IBM437 shadowing C0 and helps diagnose problems due to +// filesystem encoding or locale and also ensures these aren't +// accidentally turned into e.g. control codes and also ensures +// substitutions work correctly immediately after non-Kanji +// 2-byte ISO-2022-JP +// - · is inside Latin-1 and helps diagnose problems due to +// filesystem encoding or locale and also ensures HTML named +// character references (e.g. ·) are not used +// - ∙ is inside IBM437 and helps diagnose problems due to filesystem +// encoding or locale +// - • is inside Windows-1252 and again helps diagnose problems +// due to filesystem encoding or locale +// - ・ is inside a double-byte range of ISO-2022-JP and helps +// diagnose problems due to filesystem encoding or locale +// - ï½¥ is inside a single-byte range of ISO-2022-JP in some variants +// and helps diagnose problems due to filesystem encoding or locale; +// on the web it is distinct when decoding but unified when encoding +// - ¤ is inside Latin-1 and helps diagnose problems due to +// filesystem encoding or locale; again it is a "simple" +// substitution case +// - ≈ is inside IBM437 and helps diagnose problems due to filesystem +// encoding or locale +// - ¥‾ are inside a single-byte range of ISO-2022-JP and help +// diagnose problems due to filesystem encoding or locale +// - ~XYZ ensures earlier errors don't lead to misencoding of +// simple ASCII +// +// Overall the near-symmetry makes common I18N mistakes like +// off-by-1-after-non-BMP easier to spot. All the characters +// are also allowed in Windows Unicode filenames. +const kTestChars = 'ABC~‾¥≈¤・・•∙·☼★星🌟星★☼·∙•・・¤≈¥‾~XYZ'; + +// The kTestFallback* strings represent the expected byte sequence from +// encoding kTestChars with the given encoding with "html" replacement +// mode, isomorphic-decoded. That means, characters that can't be +// encoded in that encoding get HTML-escaped, but no further +// `escapeString`-like escapes are needed. +const kTestFallbackUtf8 = ( + "ABC~\xE2\x80\xBE\xC2\xA5\xE2\x89\x88\xC2\xA4\xEF\xBD\xA5\xE3\x83\xBB\xE2" + + "\x80\xA2\xE2\x88\x99\xC2\xB7\xE2\x98\xBC\xE2\x98\x85\xE6\x98\x9F\xF0\x9F" + + "\x8C\x9F\xE6\x98\x9F\xE2\x98\x85\xE2\x98\xBC\xC2\xB7\xE2\x88\x99\xE2\x80" + + "\xA2\xE3\x83\xBB\xEF\xBD\xA5\xC2\xA4\xE2\x89\x88\xC2\xA5\xE2\x80\xBE~XYZ" +); + +const kTestFallbackIso2022jp = ( + ("ABC~\x1B(J~\\≈¤\x1B$B!&!&\x1B(B•∙·☼\x1B$B!z@1\x1B(B🌟" + + "\x1B$B@1!z\x1B(B☼·∙•\x1B$B!&!&\x1B(B¤≈\x1B(J\\~\x1B(B~XYZ") + .replace(/[^\0-\x7F]/gu, (x) => `&#${x.codePointAt(0)};`) +); + +const kTestFallbackWindows1252 = ( + "ABC~‾\xA5≈\xA4・・\x95∙\xB7☼★星🌟星★☼\xB7∙\x95・・\xA4≈\xA5‾~XYZ".replace( + /[^\0-\xFF]/gu, + (x) => `&#${x.codePointAt(0)};`, + ) +); + +const kTestFallbackXUserDefined = kTestChars.replace( + /[^\0-\x7F]/gu, + (x) => `&#${x.codePointAt(0)};`, +); + +// formPostFileUploadTest - verifies multipart upload structure and +// numeric character reference replacement for filenames, field names, +// and field values using form submission. +// +// Uses /FileAPI/file/resources/echo-content-escaped.py to echo the +// upload POST with controls and non-ASCII bytes escaped. This is done +// because navigations whose response body contains [\0\b\v] may get +// treated as a download, which is not what we want. Use the +// `escapeString` function to replicate that kind of escape (note that +// it takes an isomorphic-decoded string, not a byte sequence). +// +// Fields in the parameter object: +// +// - fileNameSource: purely explanatory and gives a clue about which +// character encoding is the source for the non-7-bit-ASCII parts of +// the fileBaseName, or Unicode if no smaller-than-Unicode source +// contains all the characters. Used in the test name. +// - fileBaseName: the not-necessarily-just-7-bit-ASCII file basename +// used for the constructed test file. Used in the test name. +// - formEncoding: the acceptCharset of the form used to submit the +// test file. Used in the test name. +// - expectedEncodedBaseName: the expected formEncoding-encoded +// version of fileBaseName, isomorphic-decoded. That means, characters +// that can't be encoded in that encoding get HTML-escaped, but no +// further `escapeString`-like escapes are needed. +const formPostFileUploadTest = ({ + fileNameSource, + fileBaseName, + formEncoding, + expectedEncodedBaseName, +}) => { + promise_test(async testCase => { + + if (document.readyState !== 'complete') { + await new Promise(resolve => addEventListener('load', resolve)); + } + + const formTargetFrame = Object.assign(document.createElement('iframe'), { + name: 'formtargetframe', + }); + document.body.append(formTargetFrame); + testCase.add_cleanup(() => { + document.body.removeChild(formTargetFrame); + }); + + const form = Object.assign(document.createElement('form'), { + acceptCharset: formEncoding, + action: '/FileAPI/file/resources/echo-content-escaped.py', + method: 'POST', + enctype: 'multipart/form-data', + target: formTargetFrame.name, + }); + document.body.append(form); + testCase.add_cleanup(() => { + document.body.removeChild(form); + }); + + // Used to verify that the browser agrees with the test about + // which form charset is used. + form.append(Object.assign(document.createElement('input'), { + type: 'hidden', + name: '_charset_', + })); + + // Used to verify that the browser agrees with the test about + // field value replacement and encoding independently of file system + // idiosyncracies. + form.append(Object.assign(document.createElement('input'), { + type: 'hidden', + name: 'filename', + value: fileBaseName, + })); + + // Same, but with name and value reversed to ensure field names + // get the same treatment. + form.append(Object.assign(document.createElement('input'), { + type: 'hidden', + name: fileBaseName, + value: 'filename', + })); + + const fileInput = Object.assign(document.createElement('input'), { + type: 'file', + name: 'file', + }); + form.append(fileInput); + + // Removes c:\fakepath\ or other pseudofolder and returns just the + // final component of filePath; allows both / and \ as segment + // delimiters. + const baseNameOfFilePath = filePath => filePath.split(/[\/\\]/).pop(); + await new Promise(resolve => { + const dataTransfer = new DataTransfer; + dataTransfer.items.add( + new File([kTestChars], fileBaseName, {type: 'text/plain'})); + fileInput.files = dataTransfer.files; + // For historical reasons .value will be prefixed with + // c:\fakepath\, but the basename should match the file name + // exposed through the newer .files[0].name API. This check + // verifies that assumption. + assert_equals( + baseNameOfFilePath(fileInput.files[0].name), + baseNameOfFilePath(fileInput.value), + `The basename of the field's value should match its files[0].name`); + form.submit(); + formTargetFrame.onload = resolve; + }); + + const formDataText = formTargetFrame.contentDocument.body.textContent; + const formDataLines = formDataText.split('\n'); + if (formDataLines.length && !formDataLines[formDataLines.length - 1]) { + --formDataLines.length; + } + assert_greater_than( + formDataLines.length, + 2, + `${fileBaseName}: multipart form data must have at least 3 lines: ${ + JSON.stringify(formDataText) + }`); + const boundary = formDataLines[0]; + assert_equals( + formDataLines[formDataLines.length - 1], + boundary + '--', + `${fileBaseName}: multipart form data must end with ${boundary}--: ${ + JSON.stringify(formDataText) + }`); + + const asValue = expectedEncodedBaseName.replace(/\r\n?|\n/g, "\r\n"); + const asName = asValue.replace(/[\r\n"]/g, encodeURIComponent); + const asFilename = expectedEncodedBaseName.replace(/[\r\n"]/g, encodeURIComponent); + + // The response body from echo-content-escaped.py has controls and non-ASCII + // bytes escaped, so any caller-provided field that might contain such bytes + // must be passed to `escapeString`, after any other expected + // transformations. + const expectedText = [ + boundary, + 'Content-Disposition: form-data; name="_charset_"', + '', + formEncoding, + boundary, + 'Content-Disposition: form-data; name="filename"', + '', + // Unlike for names and filenames, multipart/form-data values don't escape + // \r\n linebreaks, and when they're read from an iframe they become \n. + escapeString(asValue).replace(/\r\n/g, "\n"), + boundary, + `Content-Disposition: form-data; name="${escapeString(asName)}"`, + '', + 'filename', + boundary, + `Content-Disposition: form-data; name="file"; ` + + `filename="${escapeString(asFilename)}"`, + 'Content-Type: text/plain', + '', + escapeString(kTestFallbackUtf8), + boundary + '--', + ].join('\n'); + + assert_true( + formDataText.startsWith(expectedText), + `Unexpected multipart-shaped form data received:\n${ + formDataText + }\nExpected:\n${expectedText}`); + }, `Upload ${fileBaseName} (${fileNameSource}) in ${formEncoding} form`); +}; diff --git a/test/wpt/tests/FileAPI/support/send-file-formdata-helper.js b/test/wpt/tests/FileAPI/support/send-file-formdata-helper.js new file mode 100644 index 0000000..53c8cca --- /dev/null +++ b/test/wpt/tests/FileAPI/support/send-file-formdata-helper.js @@ -0,0 +1,99 @@ +"use strict"; + +const kTestChars = "ABC~‾¥≈¤・・•∙·☼★星🌟星★☼·∙•・・¤≈¥‾~XYZ"; + +// formDataPostFileUploadTest - verifies multipart upload structure and +// numeric character reference replacement for filenames, field names, +// and field values using FormData and fetch(). +// +// Uses /fetch/api/resources/echo-content.py to echo the upload +// POST (unlike in send-file-form-helper.js, here we expect all +// multipart/form-data request bodies to be UTF-8, so we don't need to +// escape controls and non-ASCII bytes). +// +// Fields in the parameter object: +// +// - fileNameSource: purely explanatory and gives a clue about which +// character encoding is the source for the non-7-bit-ASCII parts of +// the fileBaseName, or Unicode if no smaller-than-Unicode source +// contains all the characters. Used in the test name. +// - fileBaseName: the not-necessarily-just-7-bit-ASCII file basename +// used for the constructed test file. Used in the test name. +const formDataPostFileUploadTest = ({ + fileNameSource, + fileBaseName, +}) => { + promise_test(async (testCase) => { + const formData = new FormData(); + let file = new Blob([kTestChars], { type: "text/plain" }); + try { + // Switch to File in browsers that allow this + file = new File([file], fileBaseName, { type: file.type }); + } catch (ignoredException) { + } + + // Used to verify that the browser agrees with the test about + // field value replacement and encoding independently of file system + // idiosyncracies. + formData.append("filename", fileBaseName); + + // Same, but with name and value reversed to ensure field names + // get the same treatment. + formData.append(fileBaseName, "filename"); + + formData.append("file", file, fileBaseName); + + const formDataText = await (await fetch( + `/fetch/api/resources/echo-content.py`, + { + method: "POST", + body: formData, + }, + )).text(); + const formDataLines = formDataText.split("\r\n"); + if (formDataLines.length && !formDataLines[formDataLines.length - 1]) { + --formDataLines.length; + } + assert_greater_than( + formDataLines.length, + 2, + `${fileBaseName}: multipart form data must have at least 3 lines: ${ + JSON.stringify(formDataText) + }`, + ); + const boundary = formDataLines[0]; + assert_equals( + formDataLines[formDataLines.length - 1], + boundary + "--", + `${fileBaseName}: multipart form data must end with ${boundary}--: ${ + JSON.stringify(formDataText) + }`, + ); + + const asValue = fileBaseName.replace(/\r\n?|\n/g, "\r\n"); + const asName = asValue.replace(/[\r\n"]/g, encodeURIComponent); + const asFilename = fileBaseName.replace(/[\r\n"]/g, encodeURIComponent); + const expectedText = [ + boundary, + 'Content-Disposition: form-data; name="filename"', + "", + asValue, + boundary, + `Content-Disposition: form-data; name="${asName}"`, + "", + "filename", + boundary, + `Content-Disposition: form-data; name="file"; ` + + `filename="${asFilename}"`, + "Content-Type: text/plain", + "", + kTestChars, + boundary + "--", + ].join("\r\n"); + + assert_true( + formDataText.startsWith(expectedText), + `Unexpected multipart-shaped form data received:\n${formDataText}\nExpected:\n${expectedText}`, + ); + }, `Upload ${fileBaseName} (${fileNameSource}) in fetch with FormData`); +}; diff --git a/test/wpt/tests/FileAPI/support/upload.txt b/test/wpt/tests/FileAPI/support/upload.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/test/wpt/tests/FileAPI/support/upload.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/support/url-origin.html b/test/wpt/tests/FileAPI/support/url-origin.html new file mode 100644 index 0000000..6375511 --- /dev/null +++ b/test/wpt/tests/FileAPI/support/url-origin.html @@ -0,0 +1,6 @@ + + diff --git a/test/wpt/tests/FileAPI/unicode.html b/test/wpt/tests/FileAPI/unicode.html new file mode 100644 index 0000000..ce3e357 --- /dev/null +++ b/test/wpt/tests/FileAPI/unicode.html @@ -0,0 +1,46 @@ + + +Blob/Unicode interaction: normalization and encoding + + + diff --git a/test/wpt/tests/FileAPI/url/cross-global-revoke.sub.html b/test/wpt/tests/FileAPI/url/cross-global-revoke.sub.html new file mode 100644 index 0000000..ce9d680 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/cross-global-revoke.sub.html @@ -0,0 +1,62 @@ + + + + + + + diff --git a/test/wpt/tests/FileAPI/url/multi-global-origin-serialization.sub.html b/test/wpt/tests/FileAPI/url/multi-global-origin-serialization.sub.html new file mode 100644 index 0000000..0052b26 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/multi-global-origin-serialization.sub.html @@ -0,0 +1,26 @@ + + +Blob URL serialization (specifically the origin) in multi-global situations + + + + + + + + + + + diff --git a/test/wpt/tests/FileAPI/url/resources/create-helper.html b/test/wpt/tests/FileAPI/url/resources/create-helper.html new file mode 100644 index 0000000..fa6cf4e --- /dev/null +++ b/test/wpt/tests/FileAPI/url/resources/create-helper.html @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/url/resources/create-helper.js b/test/wpt/tests/FileAPI/url/resources/create-helper.js new file mode 100644 index 0000000..e6344f7 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/resources/create-helper.js @@ -0,0 +1,4 @@ +self.addEventListener('message', e => { + let url = URL.createObjectURL(e.data.blob); + self.postMessage({url: url}); +}); diff --git a/test/wpt/tests/FileAPI/url/resources/fetch-tests.js b/test/wpt/tests/FileAPI/url/resources/fetch-tests.js new file mode 100644 index 0000000..a81ea1e --- /dev/null +++ b/test/wpt/tests/FileAPI/url/resources/fetch-tests.js @@ -0,0 +1,71 @@ +// This method generates a number of tests verifying fetching of blob URLs, +// allowing the same tests to be used both with fetch() and XMLHttpRequest. +// +// |fetch_method| is only used in test names, and should describe the +// (javascript) method being used by the other two arguments (i.e. 'fetch' or 'XHR'). +// +// |fetch_should_succeed| is a callback that is called with the Test and a URL. +// Fetching the URL is expected to succeed. The callback should return a promise +// resolved with whatever contents were fetched. +// +// |fetch_should_fail| similarly is a callback that is called with the Test, a URL +// to fetch, and optionally a method to use to do the fetch. If no method is +// specified the callback should use the 'GET' method. Fetching of these URLs is +// expected to fail, and the callback should return a promise that resolves iff +// fetching did indeed fail. +function fetch_tests(fetch_method, fetch_should_succeed, fetch_should_fail) { + const blob_contents = 'test blob contents'; + const blob = new Blob([blob_contents]); + + promise_test(t => { + const url = URL.createObjectURL(blob); + + return fetch_should_succeed(t, url).then(text => { + assert_equals(text, blob_contents); + }); + }, 'Blob URLs can be used in ' + fetch_method); + + promise_test(t => { + const url = URL.createObjectURL(blob); + + return fetch_should_succeed(t, url + '#fragment').then(text => { + assert_equals(text, blob_contents); + }); + }, fetch_method + ' with a fragment should succeed'); + + promise_test(t => { + const url = URL.createObjectURL(blob); + URL.revokeObjectURL(url); + + return fetch_should_fail(t, url); + }, fetch_method + ' of a revoked URL should fail'); + + promise_test(t => { + const url = URL.createObjectURL(blob); + URL.revokeObjectURL(url + '#fragment'); + + return fetch_should_succeed(t, url).then(text => { + assert_equals(text, blob_contents); + }); + }, 'Only exact matches should revoke URLs, using ' + fetch_method); + + promise_test(t => { + const url = URL.createObjectURL(blob); + + return fetch_should_fail(t, url + '?querystring'); + }, 'Appending a query string should cause ' + fetch_method + ' to fail'); + + promise_test(t => { + const url = URL.createObjectURL(blob); + + return fetch_should_fail(t, url + '/path'); + }, 'Appending a path should cause ' + fetch_method + ' to fail'); + + for (const method of ['HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'CUSTOM']) { + const url = URL.createObjectURL(blob); + + promise_test(t => { + return fetch_should_fail(t, url, method); + }, fetch_method + ' with method "' + method + '" should fail'); + } +} \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/url/resources/revoke-helper.html b/test/wpt/tests/FileAPI/url/resources/revoke-helper.html new file mode 100644 index 0000000..adf5a01 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/resources/revoke-helper.html @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/url/resources/revoke-helper.js b/test/wpt/tests/FileAPI/url/resources/revoke-helper.js new file mode 100644 index 0000000..c3e05b6 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/resources/revoke-helper.js @@ -0,0 +1,9 @@ +self.addEventListener('message', e => { + URL.revokeObjectURL(e.data.url); + // Registering a new object URL will make absolutely sure that the revocation + // has propagated. Without this at least in chrome it is possible for the + // below postMessage to arrive at its destination before the revocation has + // been fully processed. + URL.createObjectURL(new Blob([])); + self.postMessage('revoked'); +}); diff --git a/test/wpt/tests/FileAPI/url/sandboxed-iframe.html b/test/wpt/tests/FileAPI/url/sandboxed-iframe.html new file mode 100644 index 0000000..a52939a --- /dev/null +++ b/test/wpt/tests/FileAPI/url/sandboxed-iframe.html @@ -0,0 +1,32 @@ + + +FileAPI Test: Verify behavior of Blob URL in unique origins + + + + + + + diff --git a/test/wpt/tests/FileAPI/url/unicode-origin.sub.html b/test/wpt/tests/FileAPI/url/unicode-origin.sub.html new file mode 100644 index 0000000..2c4921c --- /dev/null +++ b/test/wpt/tests/FileAPI/url/unicode-origin.sub.html @@ -0,0 +1,23 @@ + + +FileAPI Test: Verify origin of Blob URL + + + + diff --git a/test/wpt/tests/FileAPI/url/url-charset.window.js b/test/wpt/tests/FileAPI/url/url-charset.window.js new file mode 100644 index 0000000..777709b --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-charset.window.js @@ -0,0 +1,34 @@ +async_test(t => { + // This could be detected as ISO-2022-JP, in which case there would be no + // bbb` + ], + {type: 'text/html;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const win = window.open(url); + t.add_cleanup(() => { + win.close(); + }); + + win.onload = t.step_func_done(() => { + assert_equals(win.document.charset, 'UTF-8'); + }); +}, 'Blob charset should override any auto-detected charset.'); + +async_test(t => { + const blob = new Blob( + [`\n`], + {type: 'text/html;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const win = window.open(url); + t.add_cleanup(() => { + win.close(); + }); + + win.onload = t.step_func_done(() => { + assert_equals(win.document.charset, 'UTF-8'); + }); +}, 'Blob charset should override .'); diff --git a/test/wpt/tests/FileAPI/url/url-format.any.js b/test/wpt/tests/FileAPI/url/url-format.any.js new file mode 100644 index 0000000..69c5111 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-format.any.js @@ -0,0 +1,70 @@ +// META: timeout=long +const blob = new Blob(['test']); +const file = new File(['test'], 'name'); + +test(t => { + const url_count = 5000; + let list = []; + + t.add_cleanup(() => { + for (let url of list) { + URL.revokeObjectURL(url); + } + }); + + for (let i = 0; i < url_count; ++i) + list.push(URL.createObjectURL(blob)); + + list.sort(); + + for (let i = 1; i < list.length; ++i) + assert_not_equals(list[i], list[i-1], 'generated Blob URLs should be unique'); +}, 'Generated Blob URLs are unique'); + +test(() => { + const url = URL.createObjectURL(blob); + assert_equals(typeof url, 'string'); + assert_true(url.startsWith('blob:')); +}, 'Blob URL starts with "blob:"'); + +test(() => { + const url = URL.createObjectURL(file); + assert_equals(typeof url, 'string'); + assert_true(url.startsWith('blob:')); +}, 'Blob URL starts with "blob:" for Files'); + +test(() => { + const url = URL.createObjectURL(blob); + assert_equals(new URL(url).origin, location.origin); + if (location.origin !== 'null') { + assert_true(url.includes(location.origin)); + assert_true(url.startsWith('blob:' + location.protocol)); + } +}, 'Origin of Blob URL matches our origin'); + +test(() => { + const url = URL.createObjectURL(blob); + const url_record = new URL(url); + assert_equals(url_record.protocol, 'blob:'); + assert_equals(url_record.origin, location.origin); + assert_equals(url_record.host, '', 'host should be an empty string'); + assert_equals(url_record.port, '', 'port should be an empty string'); + const uuid_path_re = /\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + assert_true(uuid_path_re.test(url_record.pathname), 'Path must end with a valid UUID'); + if (location.origin !== 'null') { + const nested_url = new URL(url_record.pathname); + assert_equals(nested_url.origin, location.origin); + assert_equals(nested_url.pathname.search(uuid_path_re), 0, 'Path must be a valid UUID'); + assert_true(url.includes(location.origin)); + assert_true(url.startsWith('blob:' + location.protocol)); + } +}, 'Blob URL parses correctly'); + +test(() => { + const url = URL.createObjectURL(file); + assert_equals(new URL(url).origin, location.origin); + if (location.origin !== 'null') { + assert_true(url.includes(location.origin)); + assert_true(url.startsWith('blob:' + location.protocol)); + } +}, 'Origin of Blob URL matches our origin for Files'); diff --git a/test/wpt/tests/FileAPI/url/url-in-tags-revoke.window.js b/test/wpt/tests/FileAPI/url/url-in-tags-revoke.window.js new file mode 100644 index 0000000..1cdad79 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-in-tags-revoke.window.js @@ -0,0 +1,115 @@ +// META: timeout=long +async_test(t => { + const run_result = 'test_frame_OK'; + const blob_contents = '\n\n' + + ''; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + + const frame = document.createElement('iframe'); + frame.setAttribute('src', url); + frame.setAttribute('style', 'display:none;'); + document.body.appendChild(frame); + URL.revokeObjectURL(url); + + frame.onload = t.step_func_done(() => { + assert_equals(frame.contentWindow.test_result, run_result); + }); +}, 'Fetching a blob URL immediately before revoking it works in an iframe.'); + +async_test(t => { + const run_result = 'test_frame_OK'; + const blob_contents = '\n\n' + + ''; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + + const frame = document.createElement('iframe'); + frame.setAttribute('src', '/common/blank.html'); + frame.setAttribute('style', 'display:none;'); + document.body.appendChild(frame); + + frame.onload = t.step_func(() => { + frame.contentWindow.location = url; + URL.revokeObjectURL(url); + frame.onload = t.step_func_done(() => { + assert_equals(frame.contentWindow.test_result, run_result); + }); + }); +}, 'Fetching a blob URL immediately before revoking it works in an iframe navigation.'); + +async_test(t => { + const run_result = 'test_frame_OK'; + const blob_contents = '\n\n' + + ''; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + const win = window.open(url); + URL.revokeObjectURL(url); + add_completion_callback(() => { win.close(); }); + + win.onload = t.step_func_done(() => { + assert_equals(win.test_result, run_result); + }); +}, 'Opening a blob URL in a new window immediately before revoking it works.'); + +function receive_message_on_channel(t, channel_name) { + const channel = new BroadcastChannel(channel_name); + return new Promise(resolve => { + channel.addEventListener('message', t.step_func(e => { + resolve(e.data); + })); + }); +} + +function window_contents_for_channel(channel_name) { + return '\n' + + ''; +} + +async_test(t => { + const channel_name = 'noopener-window-test'; + const blob = new Blob([window_contents_for_channel(channel_name)], {type: 'text/html'}); + receive_message_on_channel(t, channel_name).then(t.step_func_done(t => { + assert_equals(t, 'foobar'); + })); + const url = URL.createObjectURL(blob); + const win = window.open(); + win.opener = null; + win.location = url; + URL.revokeObjectURL(url); +}, 'Opening a blob URL in a noopener about:blank window immediately before revoking it works.'); + +async_test(t => { + const run_result = 'test_script_OK'; + const blob_contents = 'window.script_test_result = "' + run_result + '";'; + const blob = new Blob([blob_contents]); + const url = URL.createObjectURL(blob); + + const e = document.createElement('script'); + e.setAttribute('src', url); + e.onload = t.step_func_done(() => { + assert_equals(window.script_test_result, run_result); + }); + + document.body.appendChild(e); + URL.revokeObjectURL(url); +}, 'Fetching a blob URL immediately before revoking it works in '; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + + const frame = document.createElement('iframe'); + frame.setAttribute('src', url); + frame.setAttribute('style', 'display:none;'); + document.body.appendChild(frame); + + frame.onload = t.step_func_done(() => { + assert_equals(frame.contentWindow.test_result, run_result); + }); +}, 'Blob URLs can be used in iframes, and are treated same origin'); + +async_test(t => { + const blob_contents = '\n\n' + + '\n' + + '\n' + + '
\n' + + '
'; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + + const frame = document.createElement('iframe'); + frame.setAttribute('src', url + '#block2'); + document.body.appendChild(frame); + frame.contentWindow.onscroll = t.step_func_done(() => { + assert_equals(frame.contentWindow.scrollY, 5000); + }); +}, 'Blob URL fragment is implemented.'); diff --git a/test/wpt/tests/FileAPI/url/url-lifetime.html b/test/wpt/tests/FileAPI/url/url-lifetime.html new file mode 100644 index 0000000..ad5d667 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-lifetime.html @@ -0,0 +1,56 @@ + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/FileAPI/url/url-reload.window.js b/test/wpt/tests/FileAPI/url/url-reload.window.js new file mode 100644 index 0000000..d333b3a --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-reload.window.js @@ -0,0 +1,36 @@ +function blob_url_reload_test(t, revoke_before_reload) { + const run_result = 'test_frame_OK'; + const blob_contents = '\n\n' + + ''; + const blob = new Blob([blob_contents], {type: 'text/html'}); + const url = URL.createObjectURL(blob); + + const frame = document.createElement('iframe'); + frame.setAttribute('src', url); + frame.setAttribute('style', 'display:none;'); + document.body.appendChild(frame); + + frame.onload = t.step_func(() => { + if (revoke_before_reload) + URL.revokeObjectURL(url); + assert_equals(frame.contentWindow.test_result, run_result); + frame.contentWindow.test_result = null; + frame.onload = t.step_func_done(() => { + assert_equals(frame.contentWindow.test_result, run_result); + }); + // Slight delay before reloading to ensure revoke actually has had a chance + // to be processed. + t.step_timeout(() => { + frame.contentWindow.location.reload(); + }, 250); + }); +} + +async_test(t => { + blob_url_reload_test(t, false); +}, 'Reloading a blob URL succeeds.'); + + +async_test(t => { + blob_url_reload_test(t, true); +}, 'Reloading a blob URL succeeds even if the URL was revoked.'); diff --git a/test/wpt/tests/FileAPI/url/url-with-fetch.any.js b/test/wpt/tests/FileAPI/url/url-with-fetch.any.js new file mode 100644 index 0000000..54e6a3d --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-with-fetch.any.js @@ -0,0 +1,72 @@ +// META: script=resources/fetch-tests.js +// META: script=/common/gc.js + +function fetch_should_succeed(test, request) { + return fetch(request).then(response => response.text()); +} + +function fetch_should_fail(test, url, method = 'GET') { + return promise_rejects_js(test, TypeError, fetch(url, {method: method})); +} + +fetch_tests('fetch', fetch_should_succeed, fetch_should_fail); + +promise_test(t => { + const blob_contents = 'test blob contents'; + const blob_type = 'image/png'; + const blob = new Blob([blob_contents], {type: blob_type}); + const url = URL.createObjectURL(blob); + + return fetch(url).then(response => { + assert_equals(response.headers.get('Content-Type'), blob_type); + }); +}, 'fetch should return Content-Type from Blob'); + +promise_test(t => { + const blob_contents = 'test blob contents'; + const blob = new Blob([blob_contents]); + const url = URL.createObjectURL(blob); + const request = new Request(url); + + // Revoke the object URL. Request should take a reference to the blob as + // soon as it receives it in open(), so the request succeeds even though we + // revoke the URL before calling fetch(). + URL.revokeObjectURL(url); + + return fetch_should_succeed(t, request).then(text => { + assert_equals(text, blob_contents); + }); +}, 'Revoke blob URL after creating Request, will fetch'); + +promise_test(async t => { + const blob_contents = 'test blob contents'; + const blob = new Blob([blob_contents]); + const url = URL.createObjectURL(blob); + let request = new Request(url); + + // Revoke the object URL. Request should take a reference to the blob as + // soon as it receives it in open(), so the request succeeds even though we + // revoke the URL before calling fetch(). + URL.revokeObjectURL(url); + + request = request.clone(); + await garbageCollect(); + + const text = await fetch_should_succeed(t, request); + assert_equals(text, blob_contents); +}, 'Revoke blob URL after creating Request, then clone Request, will fetch'); + +promise_test(function(t) { + const blob_contents = 'test blob contents'; + const blob = new Blob([blob_contents]); + const url = URL.createObjectURL(blob); + + const result = fetch_should_succeed(t, url).then(text => { + assert_equals(text, blob_contents); + }); + + // Revoke the object URL. fetch should have already resolved the blob URL. + URL.revokeObjectURL(url); + + return result; +}, 'Revoke blob URL after calling fetch, fetch should succeed'); diff --git a/test/wpt/tests/FileAPI/url/url-with-xhr.any.js b/test/wpt/tests/FileAPI/url/url-with-xhr.any.js new file mode 100644 index 0000000..29d8308 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url-with-xhr.any.js @@ -0,0 +1,68 @@ +// META: script=resources/fetch-tests.js + +function xhr_should_succeed(test, url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.onload = test.step_func(() => { + assert_equals(xhr.status, 200); + assert_equals(xhr.statusText, 'OK'); + resolve(xhr.response); + }); + xhr.onerror = () => reject('Got unexpected error event'); + xhr.send(); + }); +} + +function xhr_should_fail(test, url, method = 'GET') { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + const result1 = new Promise((resolve, reject) => { + xhr.onload = () => reject('Got unexpected load event'); + xhr.onerror = resolve; + }); + const result2 = new Promise(resolve => { + xhr.onreadystatechange = test.step_func(() => { + if (xhr.readyState !== xhr.DONE) return; + assert_equals(xhr.status, 0); + resolve(); + }); + }); + xhr.send(); + return Promise.all([result1, result2]); +} + +fetch_tests('XHR', xhr_should_succeed, xhr_should_fail); + +async_test(t => { + const blob_contents = 'test blob contents'; + const blob_type = 'image/png'; + const blob = new Blob([blob_contents], {type: blob_type}); + const url = URL.createObjectURL(blob); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.onloadend = t.step_func_done(() => { + assert_equals(xhr.getResponseHeader('Content-Type'), blob_type); + }); + xhr.send(); +}, 'XHR should return Content-Type from Blob'); + +async_test(t => { + const blob_contents = 'test blob contents'; + const blob = new Blob([blob_contents]); + const url = URL.createObjectURL(blob); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + + // Revoke the object URL. XHR should take a reference to the blob as soon as + // it receives it in open(), so the request succeeds even though we revoke the + // URL before calling send(). + URL.revokeObjectURL(url); + + xhr.onload = t.step_func_done(() => { + assert_equals(xhr.response, blob_contents); + }); + xhr.onerror = t.unreached_func('Got unexpected error event'); + + xhr.send(); +}, 'Revoke blob URL after open(), will fetch'); diff --git a/test/wpt/tests/FileAPI/url/url_createobjecturl_file-manual.html b/test/wpt/tests/FileAPI/url/url_createobjecturl_file-manual.html new file mode 100644 index 0000000..7ae3251 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url_createobjecturl_file-manual.html @@ -0,0 +1,45 @@ + + +FileAPI Test: Creating Blob URL with File + + + + + + +
+

Test steps:

+
    +
  1. Download blue96x96.png to local.
  2. +
  3. Select the local file (blue96x96.png) to run the test.
  4. +
+
+ +
+ +
+ +
+ + + diff --git a/test/wpt/tests/FileAPI/url/url_createobjecturl_file_img-manual.html b/test/wpt/tests/FileAPI/url/url_createobjecturl_file_img-manual.html new file mode 100644 index 0000000..534c1de --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url_createobjecturl_file_img-manual.html @@ -0,0 +1,28 @@ + + +FileAPI Test: Creating Blob URL with File as image source + + + +
+

Test steps:

+
    +
  1. Download blue96x96.png to local.
  2. +
  3. Select the local file (blue96x96.png) to run the test.
  4. +
+

Pass/fail criteria:

+

Test passes if there is a filled blue square.

+ +

+

+
+ + + diff --git a/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img-ref.html b/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img-ref.html new file mode 100644 index 0000000..7d73904 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img-ref.html @@ -0,0 +1,12 @@ + + +FileAPI Reference File + + + +

Test passes if there is a filled blue square.

+ +

+ +

+ diff --git a/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img.html b/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img.html new file mode 100644 index 0000000..468dcb0 --- /dev/null +++ b/test/wpt/tests/FileAPI/url/url_xmlhttprequest_img.html @@ -0,0 +1,27 @@ + + + +FileAPI Test: Creating Blob URL via XMLHttpRequest as image source + + + + +

Test passes if there is a filled blue square.

+ +

+ +

+ + + + diff --git a/test/wpt/tests/LICENSE.md b/test/wpt/tests/LICENSE.md new file mode 100644 index 0000000..39c46d0 --- /dev/null +++ b/test/wpt/tests/LICENSE.md @@ -0,0 +1,11 @@ +# The 3-Clause BSD License + +Copyright © web-platform-tests contributors + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/test/wpt/tests/README.md b/test/wpt/tests/README.md new file mode 100644 index 0000000..7e8e994 --- /dev/null +++ b/test/wpt/tests/README.md @@ -0,0 +1,124 @@ +The web-platform-tests Project +============================== + +[![Taskcluster CI Status](https://community-tc.services.mozilla.com/api/github/v1/repository/web-platform-tests/wpt/master/badge.svg)](https://community-tc.services.mozilla.com/api/github/v1/repository/web-platform-tests/wpt/master/latest) [![documentation](https://github.com/web-platform-tests/wpt/workflows/documentation/badge.svg)](https://github.com/web-platform-tests/wpt/actions?query=workflow%3Adocumentation+branch%3Amaster) [![manifest](https://github.com/web-platform-tests/wpt/workflows/manifest/badge.svg)](https://github.com/web-platform-tests/wpt/actions?query=workflow%3Amanifest+branch%3Amaster) [![Python 3](https://pyup.io/repos/github/web-platform-tests/wpt/python-3-shield.svg)](https://pyup.io/repos/github/web-platform-tests/wpt/) + +The web-platform-tests Project is a cross-browser test suite for the +Web-platform stack. Writing tests in a way that allows them to be run in all +browsers gives browser projects confidence that they are shipping software that +is compatible with other implementations, and that later implementations will +be compatible with their implementations. This in turn gives Web +authors/developers confidence that they can actually rely on the Web platform +to deliver on the promise of working across browsers and devices without +needing extra layers of abstraction to paper over the gaps left by +specification editors and implementors. + +The most important sources of information and activity are: + +- [github.com/web-platform-tests/wpt](https://github.com/web-platform-tests/wpt): + the canonical location of the project's source code revision history and the + discussion forum for changes to the code +- [web-platform-tests.org](https://web-platform-tests.org): the documentation + website; details how to set up the project, how to write tests, how to give + and receive peer review, how to serve as an administrator, and more +- [wpt.live](https://wpt.live): a public deployment of the test suite, + allowing anyone to run the tests by visiting from an + Internet-enabled browser of their choice +- [wpt.fyi](https://wpt.fyi): an archive of test results collected from an + array of web browsers on a regular basis +- [Real-time chat room](https://app.element.io/#/room/#wpt:matrix.org): the + `wpt:matrix.org` matrix channel; includes participants located + around the world, but busiest during the European working day. +- [Mailing list](https://lists.w3.org/Archives/Public/public-test-infra/): a + public and low-traffic discussion list +- [RFCs](https://github.com/web-platform-tests/rfcs): a repo for requesting + comments on substantial changes that would impact other stakeholders or + users; people who work on WPT infra are encouraged to watch the repo. + +**If you'd like clarification about anything**, don't hesitate to ask in the +chat room or on the mailing list. + +Setting Up the Repo +=================== + +Clone or otherwise get https://github.com/web-platform-tests/wpt. + +Note: because of the frequent creation and deletion of branches in this +repo, it is recommended to "prune" stale branches when fetching updates, +i.e. use `git pull --prune` (or `git fetch -p && git merge`). + +Running the Tests +================= + +See the [documentation website](https://web-platform-tests.org/running-tests/) +and in particular the +[system setup for running tests locally](https://web-platform-tests.org/running-tests/from-local-system.html#system-setup). + +Command Line Tools +================== + +The `wpt` command provides a frontend to a variety of tools for +working with and running web-platform-tests. Some of the most useful +commands are: + +* `wpt serve` - For starting the wpt http server +* `wpt run` - For running tests in a browser +* `wpt lint` - For running the lint against all tests +* `wpt manifest` - For updating or generating a `MANIFEST.json` test manifest +* `wpt install` - For installing the latest release of a browser or + webdriver server on the local machine. +* `wpt serve-wave` - For starting the wpt http server and the WAVE test runner. +For more details on how to use the WAVE test runner see the [documentation](./tools/wave/docs/usage/usage.md). + +Windows Notes +============================================= + +On Windows `wpt` commands must be prefixed with `python` or the path +to the python binary (if `python` is not in your `%PATH%`). + +```bash +python wpt [command] +``` + +Alternatively, you may also use +[Bash on Ubuntu on Windows](https://msdn.microsoft.com/en-us/commandline/wsl/about) +in the Windows 10 Anniversary Update build, then access your windows +partition from there to launch `wpt` commands. + +Please make sure git and your text editor do not automatically convert +line endings, as it will cause lint errors. For git, please set +`git config core.autocrlf false` in your working tree. + +Publication +=========== + +The master branch is automatically synced to [wpt.live](https://wpt.live/) and +[w3c-test.org](https://w3c-test.org/). + +Contributing +============ + +Save the Web, Write Some Tests! + +Absolutely everyone is welcome to contribute to test development. No +test is too small or too simple, especially if it corresponds to +something for which you've noted an interoperability bug in a browser. + +The way to contribute is just as usual: + +* Fork this repository (and make sure you're still relatively in sync + with it if you forked a while ago). +* Create a branch for your changes: + `git checkout -b topic`. +* Make your changes. +* Run `./wpt lint` as described above. +* Commit locally and push that to your repo. +* Create a pull request based on the above. + +Issues with web-platform-tests +------------------------------ + +If you spot an issue with a test and are not comfortable providing a +pull request per above to fix it, please +[file a new issue](https://github.com/web-platform-tests/wpt/issues/new). +Thank you! diff --git a/test/wpt/tests/common/CustomCorsResponse.py b/test/wpt/tests/common/CustomCorsResponse.py new file mode 100644 index 0000000..fc4d122 --- /dev/null +++ b/test/wpt/tests/common/CustomCorsResponse.py @@ -0,0 +1,30 @@ +import json + +def main(request, response): + '''Handler for getting an HTTP response customised by the given query + parameters. + + The returned response will have + - HTTP headers defined by the 'headers' query parameter + - Must be a serialized JSON dictionary mapping header names to header + values + - HTTP status code defined by the 'status' query parameter + - Must be a positive serialized JSON integer like the string '200' + - Response content defined by the 'content' query parameter + - Must be a serialized JSON string representing the desired response body + ''' + def query_parameter_or_default(param, default): + return request.GET.first(param) if param in request.GET else default + + headers = json.loads(query_parameter_or_default(b'headers', b'"{}"')) + for k, v in headers.items(): + response.headers.set(k, v) + + # Note that, in order to have out-of-the-box support for tests that don't call + # setup({'allow_uncaught_exception': true}) + # we return a no-op JS payload. This approach will avoid syntax errors in + # script resources that would otherwise cause the test harness to fail. + response.content = json.loads(query_parameter_or_default(b'content', + b'"/* CustomCorsResponse.py content */"')) + response.status_code = json.loads(query_parameter_or_default(b'status', + b'200')) diff --git a/test/wpt/tests/common/META.yml b/test/wpt/tests/common/META.yml new file mode 100644 index 0000000..ca4d2e5 --- /dev/null +++ b/test/wpt/tests/common/META.yml @@ -0,0 +1,3 @@ +suggested_reviewers: + - zqzhang + - deniak diff --git a/test/wpt/tests/common/PrefixedLocalStorage.js b/test/wpt/tests/common/PrefixedLocalStorage.js new file mode 100644 index 0000000..2f4e7b6 --- /dev/null +++ b/test/wpt/tests/common/PrefixedLocalStorage.js @@ -0,0 +1,116 @@ +/** + * Supports pseudo-"namespacing" localStorage for a given test + * by generating and using a unique prefix for keys. Why trounce on other + * tests' localStorage items when you can keep it "separated"? + * + * PrefixedLocalStorageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix + * PrefixedLocalStorageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedLocalStorage = function () { + this.prefix = ''; // Prefix for localStorage keys + this.param = 'prefixedLocalStorage'; // Param to use in querystrings +}; + +PrefixedLocalStorage.prototype.clear = function () { + if (this.prefix === '') { return; } + Object.keys(localStorage).forEach(sKey => { + if (sKey.indexOf(this.prefix) === 0) { + localStorage.removeItem(sKey); + } + }); +}; + +/** + * Append/replace prefix parameter and value in URI querystring + * Use to generate URLs to resource files that will share the prefix. + */ +PrefixedLocalStorage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +PrefixedLocalStorage.prototype.prefixedKey = function (baseKey) { + return `${this.prefix}${baseKey}`; +}; + +PrefixedLocalStorage.prototype.setItem = function (baseKey, value) { + localStorage.setItem(this.prefixedKey(baseKey), value); +}; + +/** + * Listen for `storage` events pertaining to a particular key, + * prefixed with this object's prefix. Ignore when value is being set to null + * (i.e. removeItem). + */ +PrefixedLocalStorage.prototype.onSet = function (baseKey, fn) { + window.addEventListener('storage', e => { + var match = this.prefixedKey(baseKey); + if (e.newValue !== null && e.key.indexOf(match) === 0) { + fn.call(this, e); + } + }); +}; + +/***************************************************************************** + * Use in a testharnessjs test to generate a new key prefix. + * async_test(t => { + * var prefixedStorage = new PrefixedLocalStorageTest(); + * t.add_cleanup(() => prefixedStorage.cleanup()); + * /... + * }); + */ +var PrefixedLocalStorageTest = function () { + PrefixedLocalStorage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedLocalStorageTest.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageTest.prototype.constructor = PrefixedLocalStorageTest; + +/** + * Use in a cleanup function to clear out prefixed entries in localStorage + */ +PrefixedLocalStorageTest.prototype.cleanup = function () { + this.setItem('closeAll', 'true'); + this.clear(); +}; + +/***************************************************************************** + * Use in test resource files to share a prefix generated by a + * PrefixedLocalStorageTest. Will look in URL querystring for prefix. + * Setting `close_on_cleanup` opt truthy will make this script's window listen + * for storage `closeAll` event from controlling test and close itself. + * + * var PrefixedLocalStorageResource({ close_on_cleanup: true }); + */ +var PrefixedLocalStorageResource = function (options) { + PrefixedLocalStorage.call(this); + this.options = Object.assign({}, { + close_on_cleanup: false + }, options || {}); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } + // Optionally have this window close itself when the PrefixedLocalStorageTest + // sets a `closeAll` item. + if (this.options.close_on_cleanup) { + this.onSet('closeAll', () => { + window.close(); + }); + } +}; +PrefixedLocalStorageResource.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageResource.prototype.constructor = PrefixedLocalStorageResource; diff --git a/test/wpt/tests/common/PrefixedLocalStorage.js.headers b/test/wpt/tests/common/PrefixedLocalStorage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/PrefixedLocalStorage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/PrefixedPostMessage.js b/test/wpt/tests/common/PrefixedPostMessage.js new file mode 100644 index 0000000..674b528 --- /dev/null +++ b/test/wpt/tests/common/PrefixedPostMessage.js @@ -0,0 +1,100 @@ +/** + * Supports pseudo-"namespacing" for window-posted messages for a given test + * by generating and using a unique prefix that gets wrapped into message + * objects. This makes it more feasible to have multiple tests that use + * `window.postMessage` in a single test file. Basically, make it possible + * for the each test to listen for only the messages that are pertinent to it. + * + * 'Prefix' not an elegant term to use here but this models itself after + * PrefixedLocalStorage. + * + * PrefixedMessageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix that can be used by other test support files + * PrefixedMessageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedMessage = function () { + this.prefix = ''; + this.param = 'prefixedMessage'; // Param to use in querystrings +}; + +/** + * Generate a URL that adds/replaces param with this object's prefix + * Use to link to test support files that make use of + * PrefixedMessageResource. + */ +PrefixedMessage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +/** + * Add an eventListener on `message` but only invoke the given callback + * for messages whose object contains this object's prefix. Remove the + * event listener once the anticipated message has been received. + */ +PrefixedMessage.prototype.onMessage = function (fn) { + window.addEventListener('message', e => { + if (typeof e.data === 'object' && e.data.hasOwnProperty('prefix')) { + if (e.data.prefix === this.prefix) { + // Only invoke callback when `data` is an object containing + // a `prefix` key with this object's prefix value + // Note fn is invoked with "unwrapped" data first, then the event `e` + // (which contains the full, wrapped e.data should it be needed) + fn.call(this, e.data.data, e); + window.removeEventListener('message', fn); + } + } + }); +}; + +/** + * Instantiate in a test file (e.g. during `setup`) to create a unique-ish + * prefix that can be shared by support files + */ +var PrefixedMessageTest = function () { + PrefixedMessage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedMessageTest.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageTest.prototype.constructor = PrefixedMessageTest; + +/** + * Instantiate in a test support script to use a "prefix" generated by a + * PrefixedMessageTest in a controlling test file. It will look for + * the prefix in a URL param (see also PrefixedMessage#url) + */ +var PrefixedMessageResource = function () { + PrefixedMessage.call(this); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } +}; +PrefixedMessageResource.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageResource.prototype.constructor = PrefixedMessageResource; + +/** + * This is how a test resource document can "send info" to its + * opener context. It will whatever message is being sent (`data`) in + * an object that injects the prefix. + */ +PrefixedMessageResource.prototype.postToOpener = function (data) { + if (window.opener) { + window.opener.postMessage({ + prefix: this.prefix, + data: data + }, '*'); + } +}; diff --git a/test/wpt/tests/common/PrefixedPostMessage.js.headers b/test/wpt/tests/common/PrefixedPostMessage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/PrefixedPostMessage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/README.md b/test/wpt/tests/common/README.md new file mode 100644 index 0000000..9aef19c --- /dev/null +++ b/test/wpt/tests/common/README.md @@ -0,0 +1,10 @@ +The files in this directory are non-infrastructure support files that can be used by tests. + +* `blank.html` - An empty HTML document. +* `domain-setter.sub.html` - An HTML document that sets `document.domain`. +* `dummy.xhtml` - An XHTML document. +* `dummy.xml` - An XML document. +* `text-plain.txt` - A text/plain document. +* `*.js` - Utility scripts. These are documented in the source. +* `*.py` - wptserve [Python Handlers](https://web-platform-tests.org/writing-tests/python-handlers/). These are documented in the source. +* `security-features` - Documented in `security-features/README.md`. diff --git a/test/wpt/tests/common/__init__.py b/test/wpt/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/common/arrays.js b/test/wpt/tests/common/arrays.js new file mode 100644 index 0000000..2b31bb4 --- /dev/null +++ b/test/wpt/tests/common/arrays.js @@ -0,0 +1,31 @@ +/** + * Callback for checking equality of c and d. + * + * @callback equalityCallback + * @param {*} c + * @param {*} d + * @returns {boolean} + */ + +/** + * Returns true if the given arrays are equal. Optionally can pass an equality function. + * @param {Array} a + * @param {Array} b + * @param {equalityCallback} callbackFunction - defaults to `c === d` + * @returns {boolean} + */ +export function areArraysEqual(a, b, equalityFunction = (c, d) => { return c === d; }) { + try { + if (a.length !== b.length) + return false; + + for (let i = 0; i < a.length; i++) { + if (!equalityFunction(a[i], b[i])) + return false; + } + } catch (ex) { + return false; + } + + return true; +} diff --git a/test/wpt/tests/common/blank-with-cors.html b/test/wpt/tests/common/blank-with-cors.html new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/common/blank-with-cors.html.headers b/test/wpt/tests/common/blank-with-cors.html.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/wpt/tests/common/blank-with-cors.html.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/wpt/tests/common/blank.html b/test/wpt/tests/common/blank.html new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/common/custom-cors-response.js b/test/wpt/tests/common/custom-cors-response.js new file mode 100644 index 0000000..be9c7ce --- /dev/null +++ b/test/wpt/tests/common/custom-cors-response.js @@ -0,0 +1,32 @@ +const custom_cors_response = (payload, base_url) => { + base_url = base_url || new URL(location.href); + + // Clone the given `payload` so that, as we modify it, we won't be mutating + // the caller's value in unexpected ways. + payload = Object.assign({}, payload); + payload.headers = payload.headers || {}; + // Note that, in order to have out-of-the-box support for tests that don't + // call `setup({'allow_uncaught_exception': true})` we return a no-op JS + // payload. This approach will avoid hitting syntax errors if the resource is + // interpreted as script. Without this workaround, the SyntaxError would be + // caught by the test harness and trigger a test failure. + payload.content = payload.content || '/* custom-cors-response.js content */'; + payload.status_code = payload.status_code || 200; + + // Assume that we'll be doing a CORS-enabled fetch so we'll need to set ACAO. + const acao = "Access-Control-Allow-Origin"; + if (!(acao in payload.headers)) { + payload.headers[acao] = '*'; + } + + if (!("Content-Type" in payload.headers)) { + payload.headers["Content-Type"] = "text/javascript"; + } + + let ret = new URL("/common/CustomCorsResponse.py", base_url); + for (const key in payload) { + ret.searchParams.append(key, JSON.stringify(payload[key])); + } + + return ret; +}; diff --git a/test/wpt/tests/common/dispatcher/README.md b/test/wpt/tests/common/dispatcher/README.md new file mode 100644 index 0000000..cfaafb6 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/README.md @@ -0,0 +1,228 @@ +# `RemoteContext`: API for script execution in another context + +`RemoteContext` in `/common/dispatcher/dispatcher.js` provides an interface to +execute JavaScript in another global object (page or worker, the "executor"), +based on: + +- [WPT RFC 88: context IDs from uuid searchParams in URL](https://github.com/web-platform-tests/rfcs/pull/88), +- [WPT RFC 89: execute_script](https://github.com/web-platform-tests/rfcs/pull/89) and +- [WPT RFC 91: RemoteContext](https://github.com/web-platform-tests/rfcs/pull/91). + +Tests can send arbitrary javascript to executors to evaluate in its global +object, like: + +``` +// injector.html +const argOnLocalContext = ...; + +async function execute() { + window.open('executor.html?uuid=' + uuid); + const ctx = new RemoteContext(uuid); + await ctx.execute_script( + (arg) => functionOnRemoteContext(arg), + [argOnLocalContext]); +}; +``` + +and on executor: + +``` +// executor.html +function functionOnRemoteContext(arg) { ... } + +const uuid = new URLSearchParams(window.location.search).get('uuid'); +const executor = new Executor(uuid); +``` + +For concrete examples, see +[events.html](../../html/browsers/browsing-the-web/back-forward-cache/events.html) +and +[executor.html](../../html/browsers/browsing-the-web/back-forward-cache/resources/executor.html) +in back-forward cache tests. + +Note that `executor*` files under `/common/dispatcher/` are NOT for +`RemoteContext.execute_script()`. Use `remote-executor.html` instead. + +This is universal and avoids introducing many specific `XXX-helper.html` +resources. +Moreover, tests are easier to read, because the whole logic of the test can be +defined in a single file. + +## `new RemoteContext(uuid)` + +- `uuid` is a UUID string that identifies the remote context and should match + with the `uuid` parameter of the URL of the remote context. +- Callers should create the remote context outside this constructor (e.g. + `window.open('executor.html?uuid=' + uuid)`). + +## `RemoteContext.execute_script(fn, args)` + +- `fn` is a JavaScript function to execute on the remote context, which is + converted to a string using `toString()` and sent to the remote context. +- `args` is null or an array of arguments to pass to the function on the + remote context. Arguments are passed as JSON. +- If the return value of `fn` when executed in the remote context is a promise, + the promise returned by `execute_script` resolves to the resolved value of + that promise. Otherwise the `execute_script` promise resolves to the return + value of `fn`. + +Note that `fn` is evaluated on the remote context (`executor.html` in the +example above), while `args` are evaluated on the caller context +(`injector.html`) and then passed to the remote context. + +## Return value of injected functions and `execute_script()` + +If the return value of the injected function when executed in the remote +context is a promise, the promise returned by `execute_script` resolves to the +resolved value of that promise. Otherwise the `execute_script` promise resolves +to the return value of the function. + +When the return value of an injected script is a Promise, it should be resolved +before any navigation starts on the remote context. For example, it shouldn't +be resolved after navigating out and navigating back to the page again. +It's fine to create a Promise to be resolved after navigations, if it's not the +return value of the injected function. + +## Calling timing of `execute_script()` + +When `RemoteContext.execute_script()` is called when the remote context is not +active (for example before it is created, before navigation to the page, or +during the page is in back-forward cache), the injected script is evaluated +after the remote context becomes active. + +Multiple calls to `RemoteContext.execute_script()` will result in multiple scripts +being executed in remote context and ordering will be maintained. + +## Errors from `execute_script()` + +Errors from `execute_script()` will result in promise rejections, so it is +important to await the result. This can be `await ctx.execute_script(...)` for +every call but if there are multiple scripts to executed, it may be preferable +to wait on them in parallel to avoid incurring full round-trip time for each, +e.g. + +```js +await Promise.all( + ctx1.execute_script(...), + ctx1.execute_script(...), + ctx2.execute_script(...), + ctx2.execute_script(...), + ... +) +``` + +## Evaluation timing of injected functions + +The script injected by `RemoteContext.execute_script()` can be evaluated any +time during the remote context is active. +For example, even before DOMContentLoaded events or even during navigation. +It's the responsibility of test-specific code/helpers to ensure evaluation +timing constraints (which can be also test-specific), if any needed. + +### Ensuring evaluation timing around page load + +For example, to ensure that injected functions (`mainFunction` below) are +evaluated after the first `pageshow` event, we can use pure JavaScript code +like below: + +``` +// executor.html +window.pageShowPromise = new Promise(resolve => + window.addEventListener('pageshow', resolve, {once: true})); + + +// injector.html +const waitForPageShow = async () => { + while (!window.pageShowPromise) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + await window.pageShowPromise; +}; + +await ctx.execute(waitForPageShow); +await ctx.execute(mainFunction); +``` + +### Ensuring evaluation timing around navigation out/unloading + +It can be important to ensure there are no injected functions nor code behind +`RemoteContext` (such as Fetch APIs accessing server-side stash) running after +navigation is initiated, for example in the case of back-forward cache testing. + +To ensure this, + +- Do not call the next `RemoteContext.execute()` for the remote context after + triggering the navigation, until we are sure that the remote context is not + active (e.g. after we confirm that the new page is loaded). +- Call `Executor.suspend(callback)` synchronously within the injected script. + This suspends executor-related code, and calls `callback` when it is ready + to start navigation. + +The code on the injector side would be like: + +``` +// injector.html +await ctx.execute_script(() => { + executor.suspend(() => { + location.href = 'new-url.html'; + }); +}); +``` + +## Future Work: Possible integration with `test_driver` + +Currently `RemoteContext` is implemented by JavaScript and WPT-server-side +stash, and not integrated with `test_driver` nor `testharness`. +There is a proposal of `test_driver`-integrated version (see the RFCs listed +above). + +The API semantics and guidelines in this document are designed to be applicable +to both the current stash-based `RemoteContext` and `test_driver`-based +version, and thus the tests using `RemoteContext` will be migrated with minimum +modifications (mostly in `/common/dispatcher/dispatcher.js` and executors), for +example in a +[draft CL](https://chromium-review.googlesource.com/c/chromium/src/+/3082215/). + + +# `send()`/`receive()` Message passing APIs + +`dispatcher.js` (and its server-side backend `dispatcher.py`) provides a +universal queue-based message passing API. +Each queue is identified by a UUID, and accessed via the following APIs: + +- `send(uuid, message)` pushes a string `message` to the queue `uuid`. +- `receive(uuid)` pops the first item from the queue `uuid`. +- `showRequestHeaders(origin, uuid)` and + `cacheableShowRequestHeaders(origin, uuid)` return URLs, that push request + headers to the queue `uuid` upon fetching. + +It works cross-origin, and even access different browser context groups. + +Messages are queued, this means one doesn't need to wait for the receiver to +listen, before sending the first message +(but still need to wait for the resolution of the promise returned by `send()` +to ensure the order between `send()`s). + +## Executors + +Similar to `RemoteContext.execute_script()`, `send()`/`receive()` can be used +for sending arbitrary javascript to be evaluated in another page or worker. + +- `executor.html` (as a Document), +- `executor-worker.js` (as a Web Worker), and +- `executor-service-worker.js` (as a Service Worker) + +are examples of executors. +Note that these executors are NOT compatible with +`RemoteContext.execute_script()`. + +## Future Work + +`send()`, `receive()` and the executors below are kept for COEP/COOP tests. + +For remote script execution, new tests should use +`RemoteContext.execute_script()` instead. + +For message passing, +[WPT RFC 90](https://github.com/web-platform-tests/rfcs/pull/90) is still under +discussion. diff --git a/test/wpt/tests/common/dispatcher/dispatcher.js b/test/wpt/tests/common/dispatcher/dispatcher.js new file mode 100644 index 0000000..a0f9f43 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/dispatcher.js @@ -0,0 +1,256 @@ +// Define a universal message passing API. It works cross-origin and across +// browsing context groups. +const dispatcher_path = "/common/dispatcher/dispatcher.py"; +const dispatcher_url = new URL(dispatcher_path, location.href).href; + +// Return a promise, limiting the number of concurrent accesses to a shared +// resources to |max_concurrent_access|. +const concurrencyLimiter = (max_concurrency) => { + let pending = 0; + let waiting = []; + return async (task) => { + pending++; + if (pending > max_concurrency) + await new Promise(resolve => waiting.push(resolve)); + let result = await task(); + pending--; + waiting.shift()?.(); + return result; + }; +} + +// Wait for a random amount of time in the range [10ms,100ms]. +const randomDelay = () => { + return new Promise(resolve => setTimeout(resolve, 10 + 90*Math.random())); +} + +// Sending too many requests in parallel causes congestion. Limiting it improves +// throughput. +// +// Note: The following table has been determined on the test: +// ../cache-storage.tentative.https.html +// using Chrome with a 64 core CPU / 64GB ram, in release mode: +// ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐ +// │concurrency│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 10│ 15│ 20│ 30│ 50│ 100│ +// ├───────────┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼────┤ +// │time (s) │ 54│ 38│ 31│ 29│ 26│ 24│ 22│ 22│ 22│ 22│ 34│ 36 │ +// └───────────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘ +const limiter = concurrencyLimiter(6); + +// While requests to different remote contexts can go in parallel, we need to +// ensure that requests to each remote context are done in order. This maps a +// uuid to a queue of requests to send. A queue is processed until it is empty +// and then is deleted from the map. +const sendQueues = new Map(); + +// Sends a single item (with rate-limiting) and calls the associated resolver +// when it is successfully sent. +const sendItem = async function (uuid, resolver, message) { + await limiter(async () => { + // Requests might be dropped. Retry until getting a confirmation it has been + // processed. + while(1) { + try { + let response = await fetch(dispatcher_url + `?uuid=${uuid}`, { + method: 'POST', + body: message + }) + if (await response.text() == "done") { + resolver(); + return; + } + } catch (fetch_error) {} + await randomDelay(); + }; + }); +} + +// While the queue is non-empty, send the next item. This is async and new items +// may be added to the queue while others are being sent. +const processQueue = async function (uuid, queue) { + while (queue.length) { + const [resolver, message] = queue.shift(); + await sendItem(uuid, resolver, message); + } + // The queue is empty, delete it. + sendQueues.delete(uuid); +} + +const send = async function (uuid, message) { + const itemSentPromise = new Promise((resolve) => { + const item = [resolve, message]; + if (sendQueues.has(uuid)) { + // There is already a queue for `uuid`, just add to it and it will be processed. + sendQueues.get(uuid).push(item); + } else { + // There is no queue for `uuid`, create it and start processing. + const queue = [item]; + sendQueues.set(uuid, queue); + processQueue(uuid, queue); + } + }); + // Wait until the item has been successfully sent. + await itemSentPromise; +} + +const receive = async function (uuid) { + while(1) { + let data = "not ready"; + try { + data = await limiter(async () => { + let response = await fetch(dispatcher_url + `?uuid=${uuid}`); + return await response.text(); + }); + } catch (fetch_error) {} + + if (data == "not ready") { + await randomDelay(); + continue; + } + + return data; + } +} + +// Returns an URL. When called, the server sends toward the `uuid` queue the +// request headers. Useful for determining if something was requested with +// Cookies. +const showRequestHeaders = function(origin, uuid) { + return origin + dispatcher_path + `?uuid=${uuid}&show-headers`; +} + +// Same as above, except for the response is cacheable. +const cacheableShowRequestHeaders = function(origin, uuid) { + return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`; +} + +// This script requires +// - `/common/utils.js` for `token()`. + +// Returns the URL of a document that can be used as a `RemoteContext`. +// +// `uuid` should be a UUID uniquely identifying the given remote context. +// `options` has the following shape: +// +// { +// host: (optional) Sets the returned URL's `host` property. Useful for +// cross-origin executors. +// protocol: (optional) Sets the returned URL's `protocol` property. +// } +function remoteExecutorUrl(uuid, options) { + const url = new URL("/common/dispatcher/remote-executor.html", location); + url.searchParams.set("uuid", uuid); + + if (options?.host) { + url.host = options.host; + } + + if (options?.protocol) { + url.protocol = options.protocol; + } + + return url; +} + +// Represents a remote executor. For more detailed explanation see `README.md`. +class RemoteContext { + // `uuid` is a UUID string that identifies the remote context and should + // match with the `uuid` parameter of the URL of the remote context. + constructor(uuid) { + this.context_id = uuid; + } + + // Evaluates the script `expr` on the executor. + // - If `expr` is evaluated to a Promise that is resolved with a value: + // `execute_script()` returns a Promise resolved with the value. + // - If `expr` is evaluated to a non-Promise value: + // `execute_script()` returns a Promise resolved with the value. + // - If `expr` throws an error or is evaluated to a Promise that is rejected: + // `execute_script()` returns a rejected Promise with the error's + // `message`. + // Note that currently the type of error (e.g. DOMException) is not + // preserved, except for `TypeError`. + // The values should be able to be serialized by JSON.stringify(). + async execute_script(fn, args) { + const receiver = token(); + await this.send({receiver: receiver, fn: fn.toString(), args: args}); + const response = JSON.parse(await receive(receiver)); + if (response.status === 'success') { + return response.value; + } + + // exception + if (response.name === 'TypeError') { + throw new TypeError(response.value); + } + throw new Error(response.value); + } + + async send(msg) { + return await send(this.context_id, JSON.stringify(msg)); + } +}; + +class Executor { + constructor(uuid) { + this.uuid = uuid; + + // If `suspend_callback` is not `null`, the executor should be suspended + // when there are no ongoing tasks. + this.suspend_callback = null; + + this.execute(); + } + + // Wait until there are no ongoing tasks nor fetch requests for polling + // tasks, and then suspend the executor and call `callback()`. + // Navigation from the executor page should be triggered inside `callback()`, + // to avoid conflict with in-flight fetch requests. + suspend(callback) { + this.suspend_callback = callback; + } + + resume() { + } + + async execute() { + while(true) { + if (this.suspend_callback !== null) { + this.suspend_callback(); + this.suspend_callback = null; + // Wait for `resume()` to be called. + await new Promise(resolve => this.resume = resolve); + + // Workaround for https://crbug.com/1244230. + // Without this workaround, the executor is resumed and the fetch + // request to poll the next task is initiated synchronously from + // pageshow event after the page restored from BFCache, and the fetch + // request promise is never resolved (and thus the test results in + // timeout) due to https://crbug.com/1244230. The root cause is not yet + // known, but setTimeout() with 0ms causes the resume triggered on + // another task and seems to resolve the issue. + await new Promise(resolve => setTimeout(resolve, 0)); + + continue; + } + + const task = JSON.parse(await receive(this.uuid)); + + let response; + try { + const value = await eval(task.fn).apply(null, task.args); + response = JSON.stringify({ + status: 'success', + value: value + }); + } catch(e) { + response = JSON.stringify({ + status: 'exception', + name: e.name, + value: e.message + }); + } + await send(task.receiver, response); + } + } +} diff --git a/test/wpt/tests/common/dispatcher/dispatcher.py b/test/wpt/tests/common/dispatcher/dispatcher.py new file mode 100644 index 0000000..9fe7a38 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/dispatcher.py @@ -0,0 +1,53 @@ +import json +from wptserve.utils import isomorphic_decode + +# A server used to store and retrieve arbitrary data. +# This is used by: ./dispatcher.js +def main(request, response): + # This server is configured so that is accept to receive any requests and + # any cookies the web browser is willing to send. + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + response.headers.set(b'Access-Control-Allow-Methods', b'OPTIONS, GET, POST') + response.headers.set(b'Access-Control-Allow-Headers', b'Content-Type') + response.headers.set(b"Access-Control-Allow-Origin", request.headers.get(b"origin") or '*') + + if b"cacheable" in request.GET: + response.headers.set(b"Cache-Control", b"max-age=31536000") + else: + response.headers.set(b'Cache-Control', b'no-cache, no-store, must-revalidate') + + # CORS preflight + if request.method == u'OPTIONS': + return b'' + + uuid = request.GET[b'uuid'] + stash = request.server.stash; + + # The stash is accessed concurrently by many clients. A lock is used to + # avoid unterleaved read/write from different clients. + with stash.lock: + queue = stash.take(uuid, '/common/dispatcher') or []; + + # Push into the |uuid| queue, the requested headers. + if b"show-headers" in request.GET: + headers = {}; + for key, value in request.headers.items(): + headers[isomorphic_decode(key)] = isomorphic_decode(request.headers[key]) + headers = json.dumps(headers); + queue.append(headers); + ret = b''; + + # Push into the |uuid| queue, the posted data. + elif request.method == u'POST': + queue.append(request.body) + ret = b'done' + + # Pull from the |uuid| queue, the posted data. + else: + if len(queue) == 0: + ret = b'not ready' + else: + ret = queue.pop(0) + + stash.put(uuid, queue, '/common/dispatcher') + return ret; diff --git a/test/wpt/tests/common/dispatcher/executor-service-worker.js b/test/wpt/tests/common/dispatcher/executor-service-worker.js new file mode 100644 index 0000000..0b47d66 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/executor-service-worker.js @@ -0,0 +1,24 @@ +importScripts('./dispatcher.js'); + +const params = new URLSearchParams(location.search); +const uuid = params.get('uuid'); + +// The fetch handler must be registered before parsing the main script response. +// So do it here, for future use. +fetchHandler = () => {} +addEventListener('fetch', e => { + fetchHandler(e); +}); + +// Force ServiceWorker to immediately activate itself. +addEventListener('install', event => { + skipWaiting(); +}); + +let executeOrders = async function() { + while(true) { + let task = await receive(uuid); + eval(`(async () => {${task}})()`); + } +}; +executeOrders(); diff --git a/test/wpt/tests/common/dispatcher/executor-worker.js b/test/wpt/tests/common/dispatcher/executor-worker.js new file mode 100644 index 0000000..ea065a6 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/executor-worker.js @@ -0,0 +1,12 @@ +importScripts('./dispatcher.js'); + +const params = new URLSearchParams(location.search); +const uuid = params.get('uuid'); + +let executeOrders = async function() { + while(true) { + let task = await receive(uuid); + eval(`(async () => {${task}})()`); + } +}; +executeOrders(); diff --git a/test/wpt/tests/common/dispatcher/executor.html b/test/wpt/tests/common/dispatcher/executor.html new file mode 100644 index 0000000..5fe6a95 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/executor.html @@ -0,0 +1,15 @@ + + diff --git a/test/wpt/tests/common/dispatcher/remote-executor.html b/test/wpt/tests/common/dispatcher/remote-executor.html new file mode 100644 index 0000000..8b00303 --- /dev/null +++ b/test/wpt/tests/common/dispatcher/remote-executor.html @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/test/wpt/tests/common/domain-setter.sub.html b/test/wpt/tests/common/domain-setter.sub.html new file mode 100644 index 0000000..ad3b9f8 --- /dev/null +++ b/test/wpt/tests/common/domain-setter.sub.html @@ -0,0 +1,8 @@ + + +A page that will likely be same-origin-domain but not same-origin + + diff --git a/test/wpt/tests/common/dummy.xhtml b/test/wpt/tests/common/dummy.xhtml new file mode 100644 index 0000000..dba6945 --- /dev/null +++ b/test/wpt/tests/common/dummy.xhtml @@ -0,0 +1,2 @@ + +Dummy XHTML document diff --git a/test/wpt/tests/common/dummy.xml b/test/wpt/tests/common/dummy.xml new file mode 100644 index 0000000..4a60c30 --- /dev/null +++ b/test/wpt/tests/common/dummy.xml @@ -0,0 +1 @@ +Dummy XML document diff --git a/test/wpt/tests/common/echo.py b/test/wpt/tests/common/echo.py new file mode 100644 index 0000000..911b54a --- /dev/null +++ b/test/wpt/tests/common/echo.py @@ -0,0 +1,6 @@ +def main(request, response): + # Without X-XSS-Protection to disable non-standard XSS protection the functionality this + # resource offers is useless + response.headers.set(b"X-XSS-Protection", b"0") + response.headers.set(b"Content-Type", b"text/html") + response.content = request.GET.first(b"content") diff --git a/test/wpt/tests/common/gc.js b/test/wpt/tests/common/gc.js new file mode 100644 index 0000000..ac43a4c --- /dev/null +++ b/test/wpt/tests/common/gc.js @@ -0,0 +1,52 @@ +/** + * Does a best-effort attempt at invoking garbage collection. Attempts to use + * the standardized `TestUtils.gc()` function, but falls back to other + * environment-specific nonstandard functions, with a final result of just + * creating a lot of garbage (in which case you will get a console warning). + * + * This should generally only be used to attempt to trigger bugs and crashes + * inside tests, i.e. cases where if garbage collection happened, then this + * should not trigger some misbehavior. You cannot rely on garbage collection + * successfully trigger, or that any particular unreachable object will be + * collected. + * + * @returns {Promise} A promise you should await to ensure garbage + * collection has had a chance to complete. + */ +self.garbageCollect = async () => { + // https://testutils.spec.whatwg.org/#the-testutils-namespace + if (self.TestUtils?.gc) { + return TestUtils.gc(); + } + + // Use --expose_gc for V8 (and Node.js) + // to pass this flag at chrome launch use: --js-flags="--expose-gc" + // Exposed in SpiderMonkey shell as well + if (self.gc) { + return self.gc(); + } + + // Present in some WebKit development environments + if (self.GCController) { + return GCController.collect(); + } + + console.warn( + 'Tests are running without the ability to do manual garbage collection. ' + + 'They will still work, but coverage will be suboptimal.'); + + for (var i = 0; i < 1000; i++) { + gcRec(10); + } + + function gcRec(n) { + if (n < 1) { + return {}; + } + + let temp = { i: "ab" + i + i / 100000 }; + temp += "foo"; + + gcRec(n - 1); + } +}; diff --git a/test/wpt/tests/common/get-host-info.sub.js b/test/wpt/tests/common/get-host-info.sub.js new file mode 100644 index 0000000..9b8c2b5 --- /dev/null +++ b/test/wpt/tests/common/get-host-info.sub.js @@ -0,0 +1,63 @@ +/** + * Host information for cross-origin tests. + * @returns {Object} with properties for different host information. + */ +function get_host_info() { + + var HTTP_PORT = '{{ports[http][0]}}'; + var HTTP_PORT2 = '{{ports[http][1]}}'; + var HTTPS_PORT = '{{ports[https][0]}}'; + var HTTPS_PORT2 = '{{ports[https][1]}}'; + var PROTOCOL = self.location.protocol; + var IS_HTTPS = (PROTOCOL == "https:"); + var PORT = IS_HTTPS ? HTTPS_PORT : HTTP_PORT; + var PORT2 = IS_HTTPS ? HTTPS_PORT2 : HTTP_PORT2; + var HTTP_PORT_ELIDED = HTTP_PORT == "80" ? "" : (":" + HTTP_PORT); + var HTTP_PORT2_ELIDED = HTTP_PORT2 == "80" ? "" : (":" + HTTP_PORT2); + var HTTPS_PORT_ELIDED = HTTPS_PORT == "443" ? "" : (":" + HTTPS_PORT); + var PORT_ELIDED = IS_HTTPS ? HTTPS_PORT_ELIDED : HTTP_PORT_ELIDED; + var ORIGINAL_HOST = '{{host}}'; + var REMOTE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('www1.' + ORIGINAL_HOST); + var OTHER_HOST = '{{domains[www2]}}'; + var NOTSAMESITE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('{{hosts[alt][]}}'); + + return { + HTTP_PORT: HTTP_PORT, + HTTP_PORT2: HTTP_PORT2, + HTTPS_PORT: HTTPS_PORT, + HTTPS_PORT2: HTTPS_PORT2, + PORT: PORT, + PORT2: PORT2, + ORIGINAL_HOST: ORIGINAL_HOST, + REMOTE_HOST: REMOTE_HOST, + + ORIGIN: PROTOCOL + "//" + ORIGINAL_HOST + PORT_ELIDED, + HTTP_ORIGIN: 'http://' + ORIGINAL_HOST + HTTP_PORT_ELIDED, + HTTPS_ORIGIN: 'https://' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTPS_ORIGIN_WITH_CREDS: 'https://foo:bar@' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTP_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + ORIGINAL_HOST + HTTP_PORT2_ELIDED, + REMOTE_ORIGIN: PROTOCOL + "//" + REMOTE_HOST + PORT_ELIDED, + OTHER_ORIGIN: PROTOCOL + "//" + OTHER_HOST + PORT_ELIDED, + HTTP_REMOTE_ORIGIN: 'http://' + REMOTE_HOST + HTTP_PORT_ELIDED, + HTTP_NOTSAMESITE_ORIGIN: 'http://' + NOTSAMESITE_HOST + HTTP_PORT_ELIDED, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + REMOTE_HOST + HTTP_PORT2_ELIDED, + HTTPS_REMOTE_ORIGIN: 'https://' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_REMOTE_ORIGIN_WITH_CREDS: 'https://foo:bar@' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_NOTSAMESITE_ORIGIN: 'https://' + NOTSAMESITE_HOST + HTTPS_PORT_ELIDED, + UNAUTHENTICATED_ORIGIN: 'http://' + OTHER_HOST + HTTP_PORT_ELIDED, + AUTHENTICATED_ORIGIN: 'https://' + OTHER_HOST + HTTPS_PORT_ELIDED + }; +} + +/** + * When a default port is used, location.port returns the empty string. + * This function attempts to provide an exact port, assuming we are running under wptserve. + * @param {*} loc - can be Location///URL, but assumes http/https only. + * @returns {string} The port number. + */ +function get_port(loc) { + if (loc.port) { + return loc.port; + } + return loc.protocol === 'https:' ? '443' : '80'; +} diff --git a/test/wpt/tests/common/get-host-info.sub.js.headers b/test/wpt/tests/common/get-host-info.sub.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/get-host-info.sub.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/media.js b/test/wpt/tests/common/media.js new file mode 100644 index 0000000..800593f --- /dev/null +++ b/test/wpt/tests/common/media.js @@ -0,0 +1,61 @@ +/** + * Returns the URL of a supported video source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getVideoURI(base) +{ + var extension = '.mp4'; + + var videotag = document.createElement("video"); + + if ( videotag.canPlayType ) + { + if (videotag.canPlayType('video/webm; codecs="vp9, opus"') ) + { + extension = '.webm'; + } else if ( videotag.canPlayType('video/ogg; codecs="theora, vorbis"') ) + { + extension = '.ogv'; + } + } + + return base + extension; +} + +/** + * Returns the URL of a supported audio source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getAudioURI(base) +{ + var extension = '.mp3'; + + var audiotag = document.createElement("audio"); + + if ( audiotag.canPlayType && + audiotag.canPlayType('audio/ogg') ) + { + extension = '.oga'; + } + + return base + extension; +} + +/** + * Returns the MIME type for a media URL based on the file extension. + * @param {string} url + * @returns {string} + */ +function getMediaContentType(url) { + var extension = new URL(url, location).pathname.split(".").pop(); + var map = { + "mp4" : "video/mp4", + "ogv" : "application/ogg", + "webm": "video/webm", + "mp3" : "audio/mp3", + "oga" : "application/ogg", + }; + return map[extension]; +} diff --git a/test/wpt/tests/common/media.js.headers b/test/wpt/tests/common/media.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/media.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/object-association.js b/test/wpt/tests/common/object-association.js new file mode 100644 index 0000000..669c17c --- /dev/null +++ b/test/wpt/tests/common/object-association.js @@ -0,0 +1,74 @@ +"use strict"; + +// This is for testing whether an object (e.g., a global property) is associated with Window, or +// with Document. Recall that Window and Document are 1:1 except when doing a same-origin navigation +// away from the initial about:blank. In that case the Window object gets reused for the new +// Document. +// +// So: +// - If something is per-Window, then it should maintain its identity across an about:blank +// navigation. +// - If something is per-Document, then it should be recreated across an about:blank navigation. + +window.testIsPerWindow = propertyName => { + runTests(propertyName, assert_equals, "must not"); +}; + +window.testIsPerDocument = propertyName => { + runTests(propertyName, assert_not_equals, "must"); +}; + +function runTests(propertyName, equalityOrInequalityAsserter, mustOrMustNotReplace) { + async_test(t => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + iframe.onload = t.step_func_done(() => { + const after = frame[propertyName]; + equalityOrInequalityAsserter(after, before); + }); + + iframe.src = "/common/blank.html"; + }, `Navigating from the initial about:blank ${mustOrMustNotReplace} replace window.${propertyName}`); + + // Per spec, discarding a browsing context should not change any of the global objects. + test(() => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + iframe.remove(); + + const after = frame[propertyName]; + assert_equals(after, before, `window.${propertyName} should not change after iframe.remove()`); + }, `Discarding the browsing context must not change window.${propertyName}`); + + // Per spec, document.open() should not change any of the global objects. In historical versions + // of the spec, it did, so we test here. + async_test(t => { + const iframe = document.createElement("iframe"); + + iframe.onload = t.step_func_done(() => { + const frame = iframe.contentWindow; + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + frame.document.open(); + + const after = frame[propertyName]; + assert_equals(after, before); + + frame.document.close(); + }); + + iframe.src = "/common/blank.html"; + document.body.appendChild(iframe); + }, `document.open() must not replace window.${propertyName}`); +} diff --git a/test/wpt/tests/common/object-association.js.headers b/test/wpt/tests/common/object-association.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/object-association.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/performance-timeline-utils.js b/test/wpt/tests/common/performance-timeline-utils.js new file mode 100644 index 0000000..b20241c --- /dev/null +++ b/test/wpt/tests/common/performance-timeline-utils.js @@ -0,0 +1,56 @@ +/* +author: W3C http://www.w3.org/ +help: http://www.w3.org/TR/navigation-timing/#sec-window.performance-attribute +*/ +var performanceNamespace = window.performance; +var namespace_check = false; +function wp_test(func, msg, properties) +{ + // only run the namespace check once + if (!namespace_check) + { + namespace_check = true; + + if (performanceNamespace === undefined || performanceNamespace == null) + { + // show a single error that window.performance is undefined + // The window.performance attribute provides a hosting area for performance related attributes. + test(function() { assert_true(performanceNamespace !== undefined && performanceNamespace != null, "window.performance is defined and not null"); }, "window.performance is defined and not null."); + } + } + + test(func, msg, properties); +} + +function test_true(value, msg, properties) +{ + wp_test(function () { assert_true(value, msg); }, msg, properties); +} + +function test_equals(value, equals, msg, properties) +{ + wp_test(function () { assert_equals(value, equals, msg); }, msg, properties); +} + +// assert for every entry in `expectedEntries`, there is a matching entry _somewhere_ in `actualEntries` +function test_entries(actualEntries, expectedEntries) { + test_equals(actualEntries.length, expectedEntries.length) + expectedEntries.forEach(function (expectedEntry) { + var foundEntry = actualEntries.find(function (actualEntry) { + return typeof Object.keys(expectedEntry).find(function (key) { + return actualEntry[key] !== expectedEntry[key] + }) === 'undefined' + }) + test_true(!!foundEntry, `Entry ${JSON.stringify(expectedEntry)} could not be found.`) + if (foundEntry) { + assert_object_equals(foundEntry.toJSON(), expectedEntry) + } + }) +} + +function delayedLoadListener(callback) { + window.addEventListener('load', function() { + // TODO(cvazac) Remove this setTimeout when spec enforces sync entries. + step_timeout(callback, 0) + }) +} diff --git a/test/wpt/tests/common/performance-timeline-utils.js.headers b/test/wpt/tests/common/performance-timeline-utils.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/performance-timeline-utils.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/proxy-all.sub.pac b/test/wpt/tests/common/proxy-all.sub.pac new file mode 100644 index 0000000..de601e5 --- /dev/null +++ b/test/wpt/tests/common/proxy-all.sub.pac @@ -0,0 +1,3 @@ +function FindProxyForURL(url, host) { + return "PROXY {{host}}:{{ports[http][0]}}" +} diff --git a/test/wpt/tests/common/redirect-opt-in.py b/test/wpt/tests/common/redirect-opt-in.py new file mode 100644 index 0000000..b5e674a --- /dev/null +++ b/test/wpt/tests/common/redirect-opt-in.py @@ -0,0 +1,20 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) + response.headers.set(b"Timing-Allow-Origin", b"*") diff --git a/test/wpt/tests/common/redirect.py b/test/wpt/tests/common/redirect.py new file mode 100644 index 0000000..f2fd1eb --- /dev/null +++ b/test/wpt/tests/common/redirect.py @@ -0,0 +1,19 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) diff --git a/test/wpt/tests/common/refresh.py b/test/wpt/tests/common/refresh.py new file mode 100644 index 0000000..0d30990 --- /dev/null +++ b/test/wpt/tests/common/refresh.py @@ -0,0 +1,11 @@ +def main(request, response): + """ + Respond with a blank HTML document and a `Refresh` header which describes + an immediate redirect to the URL specified by the requests `location` query + string parameter + """ + headers = [ + (b'Content-Type', b'text/html'), + (b'Refresh', b'0; URL=' + request.GET.first(b'location')) + ] + return (200, headers, b'') diff --git a/test/wpt/tests/common/reftest-wait.js b/test/wpt/tests/common/reftest-wait.js new file mode 100644 index 0000000..64fe9bf --- /dev/null +++ b/test/wpt/tests/common/reftest-wait.js @@ -0,0 +1,39 @@ +/** + * Remove the `reftest-wait` class on the document element. + * The reftest runner will wait with taking a screenshot while + * this class is present. + * + * See https://web-platform-tests.org/writing-tests/reftests.html#controlling-when-comparison-occurs + */ +function takeScreenshot() { + document.documentElement.classList.remove("reftest-wait"); +} + +/** + * Call `takeScreenshot()` after a delay of at least |timeout| milliseconds. + * @param {number} timeout - milliseconds + */ +function takeScreenshotDelayed(timeout) { + setTimeout(function() { + takeScreenshot(); + }, timeout); +} + +/** + * Ensure that a precondition is met before waiting for a screenshot. + * @param {bool} condition - Fail the test if this evaluates to false + * @param {string} msg - Error message to write to the screenshot + */ +function failIfNot(condition, msg) { + const fail = () => { + (document.body || document.documentElement).textContent = `Precondition Failed: ${msg}`; + takeScreenshot(); + }; + if (!condition) { + if (document.readyState == "interactive") { + fail(); + } else { + document.addEventListener("DOMContentLoaded", fail, false); + } + } +} diff --git a/test/wpt/tests/common/reftest-wait.js.headers b/test/wpt/tests/common/reftest-wait.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/common/reftest-wait.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/common/rendering-utils.js b/test/wpt/tests/common/rendering-utils.js new file mode 100644 index 0000000..46283bd --- /dev/null +++ b/test/wpt/tests/common/rendering-utils.js @@ -0,0 +1,19 @@ +"use strict"; + +/** + * Waits until we have at least one frame rendered, regardless of the engine. + * + * @returns {Promise} + */ +function waitForAtLeastOneFrame() { + return new Promise(resolve => { + // Different web engines work slightly different on this area but waiting + // for two requestAnimationFrames() to happen, one after another, should be + // sufficient to ensure at least one frame has been generated anywhere. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} diff --git a/test/wpt/tests/common/sab.js b/test/wpt/tests/common/sab.js new file mode 100644 index 0000000..a3ea610 --- /dev/null +++ b/test/wpt/tests/common/sab.js @@ -0,0 +1,21 @@ +const createBuffer = (() => { + // See https://github.com/whatwg/html/issues/5380 for why not `new SharedArrayBuffer()` + let sabConstructor; + try { + sabConstructor = new WebAssembly.Memory({ shared:true, initial:0, maximum:0 }).buffer.constructor; + } catch(e) { + sabConstructor = null; + } + return (type, length, opts) => { + if (type === "ArrayBuffer") { + return new ArrayBuffer(length, opts); + } else if (type === "SharedArrayBuffer") { + if (sabConstructor && sabConstructor.name !== "SharedArrayBuffer") { + throw new Error("WebAssembly.Memory does not support shared:true"); + } + return new sabConstructor(length, opts); + } else { + throw new Error("type has to be ArrayBuffer or SharedArrayBuffer"); + } + } +})(); diff --git a/test/wpt/tests/common/security-features/README.md b/test/wpt/tests/common/security-features/README.md new file mode 100644 index 0000000..f957541 --- /dev/null +++ b/test/wpt/tests/common/security-features/README.md @@ -0,0 +1,460 @@ +This directory contains the common infrastructure for the following tests (also referred below as projects). + +- referrer-policy/ +- mixed-content/ +- upgrade-insecure-requests/ + +Subdirectories: + +- `resources`: + Serves JavaScript test helpers. +- `subresource`: + Serves subresources, with support for redirects, stash, etc. + The subresource paths are managed by `subresourceMap` and + fetched in `requestVia*()` functions in `resources/common.js`. +- `scope`: + Serves nested contexts, such as iframe documents or workers. + Used from `invokeFrom*()` functions in `resources/common.js`. +- `tools`: + Scripts that generate test HTML files. Not used while running tests. +- `/referrer-policy/generic/subresource-test`: + Sanity checking tests for subresource invocation + (This is still placed outside common/) + +# Test generator + +The test generator ([common/security-features/tools/generate.py](tools/generate.py)) generates test HTML files from templates and a seed (`spec.src.json`) that defines all the test scenarios. + +The project (i.e. a WPT subdirectory, for example `referrer-policy/`) that uses the generator should define per-project data and invoke the common generator logic in `common/security-features/tools`. + +This is the overview of the project structure: + +``` +common/security-features/ +└── tools/ - the common test generator logic + ├── spec.src.json + └── template/ - the test files templates +project-directory/ (e.g. referrer-policy/) +├── spec.src.json +├── generic/ +│ ├── test-case.sub.js - Per-project test helper +│ ├── sanity-checker.js (Used by debug target only) +│ └── spec_json.js (Used by debug target only) +└── gen/ - generated tests +``` + +## Generating the tests + +Note: When the repository already contains generated tests, [remove all generated tests](#removing-all-generated-tests) first. + +```bash +# Install json5 module if needed. +pip install --user json5 + +# Generate the test files under gen/ (HTMLs and .headers files). +path/to/common/security-features/tools/generate.py --spec path/to/project-directory/ + +# Add all generated tests to the repo. +git add path/to/project-directory/gen/ && git commit -m "Add generated tests" +``` + +This will parse the spec JSON5 files and determine which tests to generate (or skip) while using templates. + +- The default spec JSON5: `common/security-features/tools/spec.src.json`. + - Describes common configurations, such as subresource types, source context types, etc. +- The per-project spec JSON5: `project-directory/spec.src.json`. + - Describes project-specific configurations, particularly those related to test generation patterns (`specification`), policy deliveries (e.g. `delivery_type`, `delivery_value`) and `expectation`. + +For how these two spec JSON5 files are merged, see [Sub projects](#sub-projects) section. + +Note: `spec.src.json` is transitioning to JSON5 [#21710](https://github.com/web-platform-tests/wpt/issues/21710). + +During the generation, the spec is validated by ```common/security-features/tools/spec_validator.py```. This is specially important when you're making changes to `spec.src.json`. Make sure it's a valid JSON (no comments or trailing commas). The validator reports specific errors (missing keys etc.), if any. + +### Removing all generated tests + +Simply remove all files under `project-directory/gen/`. + +```bash +rm -r path/to/project-directory/gen/ +``` + +### Options for generating tests + +Note: this section is currently obsolete. Only the release template is working. + +The generator script has two targets: ```release``` and ```debug```. + +* Using **release** for the target will produce tests using a template for optimizing size and performance. The release template is intended for the official web-platform-tests and possibly other test suites. No sanity checking is done in release mode. Use this option whenever you're checking into web-platform-tests. + +* When generating for ```debug```, the produced tests will contain more verbosity and sanity checks. Use this target to identify problems with the test suites when making changes locally. Make sure you don't check in tests generated with the debug target. + +Note that **release** is the default target when invoking ```generate.py```. + + +## Sub projects + +Projects can be nested, for example to reuse a single `spec.src.json` across similar but slightly different sets of generated tests. +The directory structure would look like: + +``` +project-directory/ (e.g. referrer-policy/) +├── spec.src.json - Parent project's spec JSON +├── generic/ +│ └── test-case.sub.js - Parent project's test helper +├── gen/ - parent project's generated tests +└── sub-project-directory/ (e.g. 4K) + ├── spec.src.json - Child project's spec JSON + ├── generic/ + │ └── test-case.sub.js - Child project's test helper + └── gen/ - child project's generated tests +``` + +`generate.py --spec project-directory/sub-project-directory` generates test files under `project-directory/sub-project-directory/gen`, based on `project-directory/spec.src.json` and `project-directory/sub-project-directory/spec.src.json`. + +- The child project's `spec.src.json` is merged into parent project's `spec.src.json`. + - Two spec JSON objects are merged recursively. + - If a same key exists in both objects, the child's value overwrites the parent's value. + - If both (child's and parent's) values are arrays, then the child's value is concatenated to the parent's value. + - For debugging, `generate.py` dumps the merged spec JSON object as `generic/debug-output.spec.src.json`. +- The child project's generated tests include both of the parent and child project's `test-case.sub.js`: + ```html + + + + ``` + + +## Updating the tests + +The main test logic lives in ```project-directory/generic/test-case.sub.js``` with helper functions defined in ```/common/security-features/resources/common.js``` so you should probably start there. + +For updating the test suites you will most likely do **a subset** of the following: + +* Add a new subresource type: + + * Add a new sub-resource python script to `/common/security-features/subresource/`. + * Add a sanity check test for a sub-resource to `referrer-policy/generic/subresource-test/`. + * Add a new entry to `subresourceMap` in `/common/security-features/resources/common.js`. + * Add a new entry to `valid_subresource_names` in `/common/security-features/tools/spec_validator.py`. + * Add a new entry to `subresource_schema` in `spec.src.json`. + * Update `source_context_schema` to specify in which source context the subresource can be used. + +* Add a new subresource redirection type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18939](https://github.com/web-platform-tests/wpt/pull/18939) + +* Add a new subresource origin type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18940](https://github.com/web-platform-tests/wpt/pull/18940) + +* Add a new source context (e.g. "module sharedworker global scope") + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18904](https://github.com/web-platform-tests/wpt/pull/18904) + +* Add a new source context list (e.g. "subresource request from a dedicated worker in a ` + invoker: invokeFromIframe, + }, + "iframe": { // + invoker: invokeFromIframe, + }, + "iframe-blank": { // + invoker: invokeFromIframe, + }, + "worker-classic": { + // Classic dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {}), + }, + "worker-classic-data": { + // Classic dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {}), + }, + "worker-module": { + // Module dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {type: 'module'}), + }, + "worker-module-data": { + // Module dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {type: 'module'}), + }, + "sharedworker-classic": { + // Classic shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {}), + }, + "sharedworker-classic-data": { + // Classic shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {}), + }, + "sharedworker-module": { + // Module shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {type: 'module'}), + }, + "sharedworker-module-data": { + // Module shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {type: 'module'}), + }, + }; + + return sourceContextMap[sourceContextList[0].sourceContextType].invoker( + subresource, sourceContextList); +} + +// Quick hack to expose invokeRequest when common.sub.js is loaded either +// as a classic or module script. +self.invokeRequest = invokeRequest; + +/** + invokeFrom*() functions are helper functions with the same parameters + and return values as invokeRequest(), that are tied to specific types + of top-most environment settings objects. + For example, invokeFromIframe() is the helper function for the cases where + sourceContextList[0] is an iframe. +*/ + +/** + @param {string} workerType + "worker" (for dedicated worker) or "sharedworker". + @param {boolean} isDataUrl + true if the worker script is loaded from data: URL. + Otherwise, the script is loaded from same-origin. + @param {object} workerOptions + The `options` argument for Worker constructor. + + Other parameters and return values are the same as those of invokeRequest(). +*/ +function invokeFromWorker(workerType, isDataUrl, workerOptions, + subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + let workerUrl = + "/common/security-features/scope/worker.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + if (workerOptions.type === 'module') { + workerUrl += "&type=module"; + } + + let promise; + if (isDataUrl) { + promise = fetch(workerUrl) + .then(r => r.text()) + .then(source => { + return 'data:text/javascript;base64,' + btoa(source); + }); + } else { + promise = Promise.resolve(workerUrl); + } + + return promise + .then(url => { + if (workerType === "worker") { + const worker = new Worker(url, workerOptions); + worker.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker, "message", worker, "error", window, "error"); + } else if (workerType === "sharedworker") { + const worker = new SharedWorker(url, workerOptions); + worker.port.start(); + worker.port.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker.port, "message", worker, "error", window, "error"); + } else { + throw new Error('Invalid worker type: ' + workerType); + } + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +function invokeFromIframe(subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + const frameUrl = + "/common/security-features/scope/document.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + + let iframe; + let promise; + if (currentSourceContext.sourceContextType === 'srcdoc') { + promise = fetch(frameUrl) + .then(r => r.text()) + .then(srcdoc => { + iframe = createElement( + "iframe", {srcdoc: srcdoc}, document.body, true); + return iframe.eventPromise; + }); + } else if (currentSourceContext.sourceContextType === 'iframe') { + iframe = createElement("iframe", {src: frameUrl}, document.body, true); + promise = iframe.eventPromise; + } else if (currentSourceContext.sourceContextType === 'iframe-blank') { + let frameContent; + promise = fetch(frameUrl) + .then(r => r.text()) + .then(t => { + frameContent = t; + iframe = createElement("iframe", {}, document.body, true); + return iframe.eventPromise; + }) + .then(() => { + // Reinitialize `iframe.eventPromise` with a new promise + // that catches the load event for the document.write() below. + bindEvents(iframe); + + iframe.contentDocument.write(frameContent); + iframe.contentDocument.close(); + return iframe.eventPromise; + }); + } + + return promise + .then(() => { + const promise = bindEvents2( + window, "message", iframe, "error", window, "error"); + iframe.contentWindow.postMessage( + {subresource: subresource, + sourceContextList: sourceContextList.slice(1)}, + "*"); + return promise; + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +// SanityChecker does nothing in release mode. See sanity-checker.js for debug +// mode. +function SanityChecker() {} +SanityChecker.prototype.checkScenario = function() {}; +SanityChecker.prototype.setFailTimeout = function(test, timeout) {}; +SanityChecker.prototype.checkSubresourceResult = function() {}; diff --git a/test/wpt/tests/common/security-features/resources/common.sub.js.headers b/test/wpt/tests/common/security-features/resources/common.sub.js.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/wpt/tests/common/security-features/resources/common.sub.js.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/wpt/tests/common/security-features/scope/__init__.py b/test/wpt/tests/common/security-features/scope/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/common/security-features/scope/document.py b/test/wpt/tests/common/security-features/scope/document.py new file mode 100644 index 0000000..9a9f045 --- /dev/null +++ b/test/wpt/tests/common/security-features/scope/document.py @@ -0,0 +1,36 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b"policyDeliveries", b"[]")) + maybe_additional_headers = {} + meta = u'' + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + if delivery[u'key'] == u'referrerPolicy': + meta += u'' % delivery[u'value'] + else: + error = u'invalid delivery key' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + else: + error = u'invalid delivery key' + else: + error = u'invalid deliveryType' + + handler = lambda: util.get_template(u"document.html.template") % ({ + u"meta": meta, + u"error": error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b"text/html", + maybe_additional_headers=maybe_additional_headers) diff --git a/test/wpt/tests/common/security-features/scope/template/document.html.template b/test/wpt/tests/common/security-features/scope/template/document.html.template new file mode 100644 index 0000000..37e29f8 --- /dev/null +++ b/test/wpt/tests/common/security-features/scope/template/document.html.template @@ -0,0 +1,30 @@ + + + + %(meta)s + + + + diff --git a/test/wpt/tests/common/security-features/scope/template/worker.js.template b/test/wpt/tests/common/security-features/scope/template/worker.js.template new file mode 100644 index 0000000..7a2a6e0 --- /dev/null +++ b/test/wpt/tests/common/security-features/scope/template/worker.js.template @@ -0,0 +1,29 @@ +%(import)s + +if ('DedicatedWorkerGlobalScope' in self && + self instanceof DedicatedWorkerGlobalScope) { + self.onmessage = event => onMessageFromParent(event, self); +} else if ('SharedWorkerGlobalScope' in self && + self instanceof SharedWorkerGlobalScope) { + onconnect = event => { + const port = event.ports[0]; + port.onmessage = event => onMessageFromParent(event, port); + }; +} + +// Receive a message from the parent and start the test. +function onMessageFromParent(event, port) { + const configurationError = "%(error)s"; + if (configurationError.length > 0) { + port.postMessage({error: configurationError}); + return; + } + + invokeRequest(event.data.subresource, + event.data.sourceContextList) + .then(result => port.postMessage(result)) + .catch(e => { + const message = (e.error && e.error.stack) || e.message || "Error"; + port.postMessage({error: message}); + }); +} diff --git a/test/wpt/tests/common/security-features/scope/util.py b/test/wpt/tests/common/security-features/scope/util.py new file mode 100644 index 0000000..da5aacf --- /dev/null +++ b/test/wpt/tests/common/security-features/scope/util.py @@ -0,0 +1,43 @@ +import os + +from wptserve.utils import isomorphic_decode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath( + os.path.join(script_directory, u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code=200, + content_type=b"text/html", + payload_generator=__noop, + cache_control=b"no-cache; must-revalidate", + access_control_allow_origin=b"*", + maybe_additional_headers=None): + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + payload = payload_generator() + response.writer.write(payload) diff --git a/test/wpt/tests/common/security-features/scope/worker.py b/test/wpt/tests/common/security-features/scope/worker.py new file mode 100644 index 0000000..6b321e7 --- /dev/null +++ b/test/wpt/tests/common/security-features/scope/worker.py @@ -0,0 +1,44 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b'policyDeliveries', b'[]')) + worker_type = request.GET.first(b'type', b'classic') + commonjs_url = u'%s://%s:%s/common/security-features/resources/common.sub.js' % ( + request.url_parts.scheme, request.url_parts.hostname, + request.url_parts.port) + if worker_type == b'classic': + import_line = u'importScripts("%s");' % commonjs_url + else: + import_line = u'import "%s";' % commonjs_url + + maybe_additional_headers = {} + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + error = u' cannot be used in WorkerGlobalScope' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + elif delivery[u'key'] == u'mixedContent' and delivery[u'value'] == u'opt-in': + maybe_additional_headers[b'Content-Security-Policy'] = b'block-all-mixed-content' + elif delivery[u'key'] == u'upgradeInsecureRequests' and delivery[u'value'] == u'upgrade': + maybe_additional_headers[b'Content-Security-Policy'] = b'upgrade-insecure-requests' + else: + error = u'invalid delivery key for http-rp: %s' % delivery[u'key'] + else: + error = u'invalid deliveryType: %s' % delivery[u'deliveryType'] + + handler = lambda: util.get_template(u'worker.js.template') % ({ + u'import': import_line, + u'error': error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b'text/javascript', + maybe_additional_headers=maybe_additional_headers) diff --git a/test/wpt/tests/common/security-features/subresource/__init__.py b/test/wpt/tests/common/security-features/subresource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/common/security-features/subresource/audio.py b/test/wpt/tests/common/security-features/subresource/audio.py new file mode 100644 index 0000000..f16a0f7 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/audio.py @@ -0,0 +1,18 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"webaudio", u"resources", + u"sin_440Hz_-6dBFS_1s.wav") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"audio/wav") diff --git a/test/wpt/tests/common/security-features/subresource/document.py b/test/wpt/tests/common/security-features/subresource/document.py new file mode 100644 index 0000000..52b684a --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/document.py @@ -0,0 +1,12 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"document.html.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload) diff --git a/test/wpt/tests/common/security-features/subresource/empty.py b/test/wpt/tests/common/security-features/subresource/empty.py new file mode 100644 index 0000000..312e12c --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/empty.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return u'' + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"text/plain") diff --git a/test/wpt/tests/common/security-features/subresource/font.py b/test/wpt/tests/common/security-features/subresource/font.py new file mode 100644 index 0000000..7900079 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/font.py @@ -0,0 +1,76 @@ +import os, sys +from base64 import decodebytes + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + # Simple base64 encoded .tff font + return decodebytes(b"AAEAAAANAIAAAwBQRkZUTU6u6MkAAAXcAAAAHE9TLzJWYW" + b"QKAAABWAAAAFZjbWFwAA8D7wAAAcAAAAFCY3Z0IAAhAnkA" + b"AAMEAAAABGdhc3D//wADAAAF1AAAAAhnbHlmCC6aTwAAAx" + b"QAAACMaGVhZO8ooBcAAADcAAAANmhoZWEIkAV9AAABFAAA" + b"ACRobXR4EZQAhQAAAbAAAAAQbG9jYQBwAFQAAAMIAAAACm" + b"1heHAASQA9AAABOAAAACBuYW1lehAVOgAAA6AAAAIHcG9z" + b"dP+uADUAAAWoAAAAKgABAAAAAQAAMhPyuV8PPPUACwPoAA" + b"AAAMU4Lm0AAAAAxTgubQAh/5wFeAK8AAAACAACAAAAAAAA" + b"AAEAAAK8/5wAWgXcAAAAAAV4AAEAAAAAAAAAAAAAAAAAAA" + b"AEAAEAAAAEAAwAAwAAAAAAAgAAAAEAAQAAAEAALgAAAAAA" + b"AQXcAfQABQAAAooCvAAAAIwCigK8AAAB4AAxAQIAAAIABg" + b"kAAAAAAAAAAAABAAAAAAAAAAAAAAAAUGZFZABAAEEAQQMg" + b"/zgAWgK8AGQAAAABAAAAAAAABdwAIQAAAAAF3AAABdwAZA" + b"AAAAMAAAADAAAAHAABAAAAAAA8AAMAAQAAABwABAAgAAAA" + b"BAAEAAEAAABB//8AAABB////wgABAAAAAAAAAQYAAAEAAA" + b"AAAAAAAQIAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAhAnkAAAAqACoAKgBGAAAAAgAhAA" + b"ABKgKaAAMABwAusQEALzyyBwQA7TKxBgXcPLIDAgDtMgCx" + b"AwAvPLIFBADtMrIHBgH8PLIBAgDtMjMRIREnMxEjIQEJ6M" + b"fHApr9ZiECWAAAAwBk/5wFeAK8AAMABwALAAABNSEVATUh" + b"FQE1IRUB9AH0/UQDhPu0BRQB9MjI/tTIyP7UyMgAAAAAAA" + b"4ArgABAAAAAAAAACYATgABAAAAAAABAAUAgQABAAAAAAAC" + b"AAYAlQABAAAAAAADACEA4AABAAAAAAAEAAUBDgABAAAAAA" + b"AFABABNgABAAAAAAAGAAUBUwADAAEECQAAAEwAAAADAAEE" + b"CQABAAoAdQADAAEECQACAAwAhwADAAEECQADAEIAnAADAA" + b"EECQAEAAoBAgADAAEECQAFACABFAADAAEECQAGAAoBRwBD" + b"AG8AcAB5AHIAaQBnAGgAdAAgACgAYwApACAAMgAwADAAOA" + b"AgAE0AbwB6AGkAbABsAGEAIABDAG8AcgBwAG8AcgBhAHQA" + b"aQBvAG4AAENvcHlyaWdodCAoYykgMjAwOCBNb3ppbGxhIE" + b"NvcnBvcmF0aW9uAABNAGEAcgBrAEEAAE1hcmtBAABNAGUA" + b"ZABpAHUAbQAATWVkaXVtAABGAG8AbgB0AEYAbwByAGcAZQ" + b"AgADIALgAwACAAOgAgAE0AYQByAGsAQQAgADoAIAA1AC0A" + b"MQAxAC0AMgAwADAAOAAARm9udEZvcmdlIDIuMCA6IE1hcm" + b"tBIDogNS0xMS0yMDA4AABNAGEAcgBrAEEAAE1hcmtBAABW" + b"AGUAcgBzAGkAbwBuACAAMAAwADEALgAwADAAMAAgAABWZX" + b"JzaW9uIDAwMS4wMDAgAABNAGEAcgBrAEEAAE1hcmtBAAAA" + b"AgAAAAAAAP+DADIAAAABAAAAAAAAAAAAAAAAAAAAAAAEAA" + b"AAAQACACQAAAAAAAH//wACAAAAAQAAAADEPovuAAAAAMU4" + b"Lm0AAAAAxTgubQ==") + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'application/x-font-truetype' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/wpt/tests/common/security-features/subresource/image.py b/test/wpt/tests/common/security-features/subresource/image.py new file mode 100644 index 0000000..5c9a0c0 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/image.py @@ -0,0 +1,116 @@ +import os, sys, array, math + +from io import BytesIO + +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +class Image: + """This class partially implements the interface of the PIL.Image.Image. + One day in the future WPT might support the PIL module or another imaging + library, so this hacky BMP implementation will no longer be required. + """ + def __init__(self, width, height): + self.width = width + self.height = height + self.img = bytearray([0 for i in range(3 * width * height)]) + + @staticmethod + def new(mode, size, color=0): + return Image(size[0], size[1]) + + def _int_to_bytes(self, number): + packed_bytes = [0, 0, 0, 0] + for i in range(4): + packed_bytes[i] = number & 0xFF + number >>= 8 + + return packed_bytes + + def putdata(self, color_data): + for y in range(self.height): + for x in range(self.width): + i = x + y * self.width + if i > len(color_data) - 1: + return + + self.img[i * 3: i * 3 + 3] = color_data[i][::-1] + + def save(self, f, type): + assert type == "BMP" + # 54 bytes of preambule + image color data. + filesize = 54 + 3 * self.width * self.height + # 14 bytes of header. + bmpfileheader = bytearray([ord('B'), ord('M')] + self._int_to_bytes(filesize) + + [0, 0, 0, 0, 54, 0, 0, 0]) + # 40 bytes of info. + bmpinfoheader = bytearray([40, 0, 0, 0] + + self._int_to_bytes(self.width) + + self._int_to_bytes(self.height) + + [1, 0, 24] + (25 * [0])) + + padlength = (4 - (self.width * 3) % 4) % 4 + bmppad = bytearray([0, 0, 0]) + padding = bmppad[0 : padlength] + + f.write(bmpfileheader) + f.write(bmpinfoheader) + + for i in range(self.height): + offset = self.width * (self.height - i - 1) * 3 + f.write(self.img[offset : offset + 3 * self.width]) + f.write(padding) + +def encode_string_as_bmp_image(string_data): + data_bytes = array.array("B", string_data.encode("utf-8")) + + num_bytes = len(data_bytes) + + # Encode data bytes to color data (RGB), one bit per channel. + # This is to avoid errors due to different color spaces used in decoding. + color_data = [] + for byte in data_bytes: + p = [int(x) * 255 for x in '{0:08b}'.format(byte)] + color_data.append((p[0], p[1], p[2])) + color_data.append((p[3], p[4], p[5])) + color_data.append((p[6], p[7], 0)) + + # Render image. + num_pixels = len(color_data) + sqrt = int(math.ceil(math.sqrt(num_pixels))) + img = Image.new("RGB", (sqrt, sqrt), "black") + img.putdata(color_data) + + # Flush image to string. + f = BytesIO() + img.save(f, "BMP") + f.seek(0) + + return f.read() + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + data = encode_string_as_bmp_image(data) + return data + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/bmp' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/wpt/tests/common/security-features/subresource/referrer.py b/test/wpt/tests/common/security-features/subresource/referrer.py new file mode 100644 index 0000000..e366314 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/referrer.py @@ -0,0 +1,4 @@ +def main(request, response): + referrer = request.headers.get(b"referer", b"") + response_headers = [(b"Content-Type", b"text/javascript")] + return (200, response_headers, b"window.referrer = '" + referrer + b"'") diff --git a/test/wpt/tests/common/security-features/subresource/script.py b/test/wpt/tests/common/security-features/subresource/script.py new file mode 100644 index 0000000..9701816 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/script.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"script.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/wpt/tests/common/security-features/subresource/shared-worker.py b/test/wpt/tests/common/security-features/subresource/shared-worker.py new file mode 100644 index 0000000..bdfb61b --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/shared-worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"shared-worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/wpt/tests/common/security-features/subresource/static-import.py b/test/wpt/tests/common/security-features/subresource/static-import.py new file mode 100644 index 0000000..3c3a6f6 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/static-import.py @@ -0,0 +1,61 @@ +import os, sys, json +from urllib.parse import unquote + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def get_csp_value(value): + ''' + Returns actual CSP header values (e.g. "worker-src 'self'") for the + given string used in PolicyDelivery's value (e.g. "worker-src-self"). + ''' + + # script-src + # Test-related scripts like testharness.js and inline scripts containing + # test bodies. + # 'unsafe-inline' is added as a workaround here. This is probably not so + # bad, as it shouldn't intefere non-inline-script requests that we want to + # test. + if value == 'script-src-wildcard': + return "script-src * 'unsafe-inline'" + if value == 'script-src-self': + return "script-src 'self' 'unsafe-inline'" + # Workaround for "script-src 'none'" would be more complicated, because + # - "script-src 'none' 'unsafe-inline'" is handled somehow differently from + # "script-src 'none'", i.e. + # https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3 + # handles the latter but not the former. + # - We need nonce- or path-based additional values to allow same-origin + # test scripts like testharness.js. + # Therefore, we disable 'script-src-none' tests for now in + # `/content-security-policy/spec.src.json`. + if value == 'script-src-none': + return "script-src 'none'" + + # worker-src + if value == 'worker-src-wildcard': + return 'worker-src *' + if value == 'worker-src-self': + return "worker-src 'self'" + if value == 'worker-src-none': + return "worker-src 'none'" + raise Exception('Invalid delivery_value: %s' % value) + +def generate_payload(request): + import_url = unquote(isomorphic_decode(request.GET[b'import_url'])) + return subresource.get_template(u"static-import.js.template") % { + u"import_url": import_url + } + +def main(request, response): + def payload_generator(_): return generate_payload(request) + maybe_additional_headers = {} + if b'contentSecurityPolicy' in request.GET: + csp = unquote(isomorphic_decode(request.GET[b'contentSecurityPolicy'])) + maybe_additional_headers[b'Content-Security-Policy'] = get_csp_value(csp) + subresource.respond(request, + response, + payload_generator = payload_generator, + content_type = b"application/javascript", + maybe_additional_headers = maybe_additional_headers) diff --git a/test/wpt/tests/common/security-features/subresource/stylesheet.py b/test/wpt/tests/common/security-features/subresource/stylesheet.py new file mode 100644 index 0000000..05db249 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/stylesheet.py @@ -0,0 +1,61 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + type = b'image' + if b"type" in request.GET: + type = request.GET[b"type"] + + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + + if type == b'image': + return subresource.get_template(u"image.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'font': + return subresource.get_template(u"font.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'svg': + return subresource.get_template(u"svg.css.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + + # A `'stylesheet-only'`-type stylesheet has no nested resources; this is + # useful in tests that cover referrers for stylesheet fetches (e.g. fetches + # triggered by `@import` statements). + elif type == b'stylesheet-only': + return u'' + +def generate_import_rule(request, server_data): + return u"@import url('%(url)s');" % { + u"url": subresource.create_url(request, swap_origin=True, + query_parameter_to_remove=u"import-rule") + } + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + payload_generator = lambda data: generate_payload(request, data) + content_type = b"text/css" + referrer_policy = b"unsafe-url" + if b"import-rule" in request.GET: + payload_generator = lambda data: generate_import_rule(request, data) + + if b"report-headers" in request.GET: + payload_generator = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + if b"referrer-policy" in request.GET: + referrer_policy = request.GET[b"referrer-policy"] + + subresource.respond( + request, + response, + payload_generator = payload_generator, + content_type = content_type, + maybe_additional_headers = { b"Referrer-Policy": referrer_policy }) diff --git a/test/wpt/tests/common/security-features/subresource/subresource.py b/test/wpt/tests/common/security-features/subresource/subresource.py new file mode 100644 index 0000000..b3c055a --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/subresource.py @@ -0,0 +1,199 @@ +import os, json +from urllib.parse import parse_qsl, SplitResult, urlencode, urlsplit, urlunsplit + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath(os.path.join(script_directory, + u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def redirect(url, response): + response.add_required_headers = False + response.writer.write_status(301) + response.writer.write_header(b"access-control-allow-origin", b"*") + response.writer.write_header(b"location", isomorphic_encode(url)) + response.writer.end_headers() + response.writer.write(u"") + + +# TODO(kristijanburnik): subdomain_prefix is a hardcoded value aligned with +# referrer-policy-test-case.js. The prefix should be configured in one place. +def __get_swapped_origin_netloc(netloc, subdomain_prefix = u"www1."): + if netloc.startswith(subdomain_prefix): + return netloc[len(subdomain_prefix):] + else: + return subdomain_prefix + netloc + + +# Creates a URL (typically a redirect target URL) that is the same as the +# current request URL `request.url`, except for: +# - When `swap_scheme` or `swap_origin` is True, its scheme/origin is changed +# to the other one. (http <-> https, ws <-> wss, etc.) +# - For `downgrade`, we redirect to a URL that would be successfully loaded +# if and only if upgrade-insecure-request is applied. +# - `query_parameter_to_remove` parameter is removed from query part. +# Its default is "redirection" to avoid redirect loops. +def create_url(request, + swap_scheme=False, + swap_origin=False, + downgrade=False, + query_parameter_to_remove=u"redirection"): + parsed = urlsplit(request.url) + destination_netloc = parsed.netloc + + scheme = parsed.scheme + if swap_scheme: + scheme = u"http" if parsed.scheme == u"https" else u"https" + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if downgrade: + # These rely on some unintuitive cleverness due to WPT's test setup: + # 'Upgrade-Insecure-Requests' does not upgrade the port number, + # so we use URLs in the form `http://[domain]:[https-port]`, + # which will be upgraded to `https://[domain]:[https-port]`. + # If the upgrade fails, the load will fail, as we don't serve HTTP over + # the secure port. + if parsed.scheme == u"https": + scheme = u"http" + elif parsed.scheme == u"wss": + scheme = u"ws" + else: + raise ValueError(u"Downgrade redirection: Invalid scheme '%s'" % + parsed.scheme) + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][parsed.scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if swap_origin: + destination_netloc = __get_swapped_origin_netloc(destination_netloc) + + parsed_query = parse_qsl(parsed.query, keep_blank_values=True) + parsed_query = [x for x in parsed_query if x[0] != query_parameter_to_remove] + + destination_url = urlunsplit(SplitResult( + scheme = scheme, + netloc = destination_netloc, + path = parsed.path, + query = urlencode(parsed_query), + fragment = None)) + + return destination_url + + +def preprocess_redirection(request, response): + if b"redirection" not in request.GET: + return False + + redirection = request.GET[b"redirection"] + + if redirection == b"no-redirect": + return False + elif redirection == b"keep-scheme": + redirect_url = create_url(request, swap_scheme=False) + elif redirection == b"swap-scheme": + redirect_url = create_url(request, swap_scheme=True) + elif redirection == b"downgrade": + redirect_url = create_url(request, downgrade=True) + elif redirection == b"keep-origin": + redirect_url = create_url(request, swap_origin=False) + elif redirection == b"swap-origin": + redirect_url = create_url(request, swap_origin=True) + else: + raise ValueError(u"Invalid redirection type '%s'" % isomorphic_decode(redirection)) + + redirect(redirect_url, response) + return True + + +def preprocess_stash_action(request, response): + if b"action" not in request.GET: + return False + + action = request.GET[b"action"] + + key = request.GET[b"key"] + stash = request.server.stash + path = request.GET[b"path"] if b"path" in request.GET \ + else isomorphic_encode(request.url.split(u'?')[0]) + + if action == b"put": + value = isomorphic_decode(request.GET[b"value"]) + stash.take(key=key, path=path) + stash.put(key=key, value=value, path=path) + response_data = json.dumps({u"status": u"success", u"result": isomorphic_decode(key)}) + elif action == b"purge": + value = stash.take(key=key, path=path) + return False + elif action == b"take": + value = stash.take(key=key, path=path) + if value is None: + status = u"allowed" + else: + status = u"blocked" + response_data = json.dumps({u"status": status, u"result": value}) + else: + return False + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-type", b"text/javascript") + response.writer.write_header(b"cache-control", b"no-cache; must-revalidate") + response.writer.end_headers() + response.writer.write(response_data) + return True + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code = 200, + content_type = b"text/html", + payload_generator = __noop, + cache_control = b"no-cache; must-revalidate", + access_control_allow_origin = b"*", + maybe_additional_headers = None): + if preprocess_redirection(request, response): + return + + if preprocess_stash_action(request, response): + return + + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + new_headers = {} + new_val = [] + for key, val in request.headers.items(): + if len(val) == 1: + new_val = isomorphic_decode(val[0]) + else: + new_val = [isomorphic_decode(x) for x in val] + new_headers[isomorphic_decode(key)] = new_val + + server_data = {u"headers": json.dumps(new_headers, indent = 4)} + + payload = payload_generator(server_data) + response.writer.write(payload) diff --git a/test/wpt/tests/common/security-features/subresource/svg.py b/test/wpt/tests/common/security-features/subresource/svg.py new file mode 100644 index 0000000..9c569e3 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/svg.py @@ -0,0 +1,37 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + with request.server.stash.lock: + request.server.stash.take(request.GET[b"id"]) + request.server.stash.put(request.GET[b"id"], data) + return u"" + +def generate_payload_embedded(request, server_data): + return subresource.get_template(u"svg.embedded.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/svg+xml' + + if b"embedded-svg" in request.GET: + handler = lambda data: generate_payload_embedded(request, data) + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type) diff --git a/test/wpt/tests/common/security-features/subresource/template/document.html.template b/test/wpt/tests/common/security-features/subresource/template/document.html.template new file mode 100644 index 0000000..141711c --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/document.html.template @@ -0,0 +1,16 @@ + + + + This page reports back it's request details to the parent frame + + + + + diff --git a/test/wpt/tests/common/security-features/subresource/template/font.css.template b/test/wpt/tests/common/security-features/subresource/template/font.css.template new file mode 100644 index 0000000..9d1e9c4 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/font.css.template @@ -0,0 +1,9 @@ +@font-face { + font-family: 'wpt'; + font-style: normal; + font-weight: normal; + src: url(/common/security-features/subresource/font.py?id=%(id)s) format('truetype'); +} +body { + font-family: 'wpt'; +} diff --git a/test/wpt/tests/common/security-features/subresource/template/image.css.template b/test/wpt/tests/common/security-features/subresource/template/image.css.template new file mode 100644 index 0000000..dfe41f1 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/image.css.template @@ -0,0 +1,3 @@ +div.styled::before { + content:url(/common/security-features/subresource/image.py?id=%(id)s) +} diff --git a/test/wpt/tests/common/security-features/subresource/template/script.js.template b/test/wpt/tests/common/security-features/subresource/template/script.js.template new file mode 100644 index 0000000..e2edf21 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/script.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}, "*"); diff --git a/test/wpt/tests/common/security-features/subresource/template/shared-worker.js.template b/test/wpt/tests/common/security-features/subresource/template/shared-worker.js.template new file mode 100644 index 0000000..c3f109e --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/shared-worker.js.template @@ -0,0 +1,5 @@ +onconnect = function(e) { + e.ports[0].postMessage({ + "headers": %(headers)s + }); +}; diff --git a/test/wpt/tests/common/security-features/subresource/template/static-import.js.template b/test/wpt/tests/common/security-features/subresource/template/static-import.js.template new file mode 100644 index 0000000..095459b --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/static-import.js.template @@ -0,0 +1 @@ +import '%(import_url)s'; diff --git a/test/wpt/tests/common/security-features/subresource/template/svg.css.template b/test/wpt/tests/common/security-features/subresource/template/svg.css.template new file mode 100644 index 0000000..c2e509c --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/svg.css.template @@ -0,0 +1,3 @@ +path { + %(property)s: url(/common/security-features/subresource/svg.py?id=%(id)s#invalidFragment); +} diff --git a/test/wpt/tests/common/security-features/subresource/template/svg.embedded.template b/test/wpt/tests/common/security-features/subresource/template/svg.embedded.template new file mode 100644 index 0000000..5986c48 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/svg.embedded.template @@ -0,0 +1,5 @@ + + + + + diff --git a/test/wpt/tests/common/security-features/subresource/template/worker.js.template b/test/wpt/tests/common/security-features/subresource/template/worker.js.template new file mode 100644 index 0000000..817dd8c --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/template/worker.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}); diff --git a/test/wpt/tests/common/security-features/subresource/video.py b/test/wpt/tests/common/security-features/subresource/video.py new file mode 100644 index 0000000..7cfbbfa --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/video.py @@ -0,0 +1,17 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"media", u"movie_5.ogv") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"video/ogg") diff --git a/test/wpt/tests/common/security-features/subresource/worker.py b/test/wpt/tests/common/security-features/subresource/worker.py new file mode 100644 index 0000000..f655633 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/wpt/tests/common/security-features/subresource/xhr.py b/test/wpt/tests/common/security-features/subresource/xhr.py new file mode 100644 index 0000000..75921e9 --- /dev/null +++ b/test/wpt/tests/common/security-features/subresource/xhr.py @@ -0,0 +1,16 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + data = (u'{"headers": %(headers)s}') % server_data + return data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"application/json", + cache_control = b"no-store") diff --git a/test/wpt/tests/common/security-features/tools/format_spec_src_json.py b/test/wpt/tests/common/security-features/tools/format_spec_src_json.py new file mode 100644 index 0000000..d1bf581 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/format_spec_src_json.py @@ -0,0 +1,24 @@ +import collections +import json +import os + + +def main(): + '''Formats spec.src.json.''' + script_directory = os.path.dirname(os.path.abspath(__file__)) + for dir in [ + 'mixed-content', 'referrer-policy', 'referrer-policy/4K-1', + 'referrer-policy/4K', 'referrer-policy/4K+1', + 'upgrade-insecure-requests' + ]: + filename = os.path.join(script_directory, '..', '..', '..', dir, + 'spec.src.json') + spec = json.load( + open(filename, 'r'), object_pairs_hook=collections.OrderedDict) + with open(filename, 'w') as f: + f.write(json.dumps(spec, indent=2, separators=(',', ': '))) + f.write('\n') + + +if __name__ == '__main__': + main() diff --git a/test/wpt/tests/common/security-features/tools/generate.py b/test/wpt/tests/common/security-features/tools/generate.py new file mode 100644 index 0000000..409b4f1 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/generate.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 + +import argparse +import collections +import copy +import json +import os +import sys + +import spec_validator +import util + + +def expand_pattern(expansion_pattern, test_expansion_schema): + expansion = {} + for artifact_key in expansion_pattern: + artifact_value = expansion_pattern[artifact_key] + if artifact_value == '*': + expansion[artifact_key] = test_expansion_schema[artifact_key] + elif isinstance(artifact_value, list): + expansion[artifact_key] = artifact_value + elif isinstance(artifact_value, dict): + # Flattened expansion. + expansion[artifact_key] = [] + values_dict = expand_pattern(artifact_value, + test_expansion_schema[artifact_key]) + for sub_key in values_dict.keys(): + expansion[artifact_key] += values_dict[sub_key] + else: + expansion[artifact_key] = [artifact_value] + + return expansion + + +def permute_expansion(expansion, + artifact_order, + selection={}, + artifact_index=0): + assert isinstance(artifact_order, list), "artifact_order should be a list" + + if artifact_index >= len(artifact_order): + yield selection + return + + artifact_key = artifact_order[artifact_index] + + for artifact_value in expansion[artifact_key]: + selection[artifact_key] = artifact_value + for next_selection in permute_expansion(expansion, artifact_order, + selection, artifact_index + 1): + yield next_selection + + +# Dumps the test config `selection` into a serialized JSON string. +def dump_test_parameters(selection): + return json.dumps( + selection, + indent=2, + separators=(',', ': '), + sort_keys=True, + cls=util.CustomEncoder) + + +def get_test_filename(spec_directory, spec_json, selection): + '''Returns the filname for the main test HTML file''' + + selection_for_filename = copy.deepcopy(selection) + # Use 'unset' rather than 'None' in test filenames. + if selection_for_filename['delivery_value'] is None: + selection_for_filename['delivery_value'] = 'unset' + + return os.path.join( + spec_directory, + spec_json['test_file_path_pattern'] % selection_for_filename) + + +def get_csp_value(value): + ''' + Returns actual CSP header values (e.g. "worker-src 'self'") for the + given string used in PolicyDelivery's value (e.g. "worker-src-self"). + ''' + + # script-src + # Test-related scripts like testharness.js and inline scripts containing + # test bodies. + # 'unsafe-inline' is added as a workaround here. This is probably not so + # bad, as it shouldn't intefere non-inline-script requests that we want to + # test. + if value == 'script-src-wildcard': + return "script-src * 'unsafe-inline'" + if value == 'script-src-self': + return "script-src 'self' 'unsafe-inline'" + # Workaround for "script-src 'none'" would be more complicated, because + # - "script-src 'none' 'unsafe-inline'" is handled somehow differently from + # "script-src 'none'", i.e. + # https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3 + # handles the latter but not the former. + # - We need nonce- or path-based additional values to allow same-origin + # test scripts like testharness.js. + # Therefore, we disable 'script-src-none' tests for now in + # `/content-security-policy/spec.src.json`. + if value == 'script-src-none': + return "script-src 'none'" + + # worker-src + if value == 'worker-src-wildcard': + return 'worker-src *' + if value == 'worker-src-self': + return "worker-src 'self'" + if value == 'worker-src-none': + return "worker-src 'none'" + raise Exception('Invalid delivery_value: %s' % value) + +def handle_deliveries(policy_deliveries): + ''' + Generate elements and HTTP headers for the given list of + PolicyDelivery. + TODO(hiroshige): Merge duplicated code here, scope/document.py, etc. + ''' + + meta = '' + headers = {} + + for delivery in policy_deliveries: + if delivery.value is None: + continue + if delivery.key == 'referrerPolicy': + if delivery.delivery_type == 'meta': + meta += \ + '' % delivery.value + elif delivery.delivery_type == 'http-rp': + headers['Referrer-Policy'] = delivery.value + # TODO(kristijanburnik): Limit to WPT origins. + headers['Access-Control-Allow-Origin'] = '*' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'mixedContent': + assert (delivery.value == 'opt-in') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = 'block-all-mixed-content' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'contentSecurityPolicy': + csp_value = get_csp_value(delivery.value) + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = csp_value + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'upgradeInsecureRequests': + # https://w3c.github.io/webappsec-upgrade-insecure-requests/#delivery + assert (delivery.value == 'upgrade') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers[ + 'Content-Security-Policy'] = 'upgrade-insecure-requests' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + else: + raise Exception('Invalid delivery_key: %s' % delivery.key) + return {"meta": meta, "headers": headers} + + +def generate_selection(spec_json, selection): + ''' + Returns a scenario object (with a top-level source_context_list entry, + which will be removed in generate_test_file() later). + ''' + + target_policy_delivery = util.PolicyDelivery(selection['delivery_type'], + selection['delivery_key'], + selection['delivery_value']) + del selection['delivery_type'] + del selection['delivery_key'] + del selection['delivery_value'] + + # Parse source context list and policy deliveries of source contexts. + # `util.ShouldSkip()` exceptions are raised if e.g. unsuppported + # combinations of source contexts and policy deliveries are used. + source_context_list_scheme = spec_json['source_context_list_schema'][ + selection['source_context_list']] + selection['source_context_list'] = [ + util.SourceContext.from_json(source_context, target_policy_delivery, + spec_json['source_context_schema']) + for source_context in source_context_list_scheme['sourceContextList'] + ] + + # Check if the subresource is supported by the innermost source context. + innermost_source_context = selection['source_context_list'][-1] + supported_subresource = spec_json['source_context_schema'][ + 'supported_subresource'][innermost_source_context.source_context_type] + if supported_subresource != '*': + if selection['subresource'] not in supported_subresource: + raise util.ShouldSkip() + + # Parse subresource policy deliveries. + selection[ + 'subresource_policy_deliveries'] = util.PolicyDelivery.list_from_json( + source_context_list_scheme['subresourcePolicyDeliveries'], + target_policy_delivery, spec_json['subresource_schema'] + ['supported_delivery_type'][selection['subresource']]) + + # Generate per-scenario test description. + selection['test_description'] = spec_json[ + 'test_description_template'] % selection + + return selection + + +def generate_test_file(spec_directory, test_helper_filenames, + test_html_template_basename, test_filename, scenarios): + ''' + Generates a test HTML file (and possibly its associated .headers file) + from `scenarios`. + ''' + + # Scenarios for the same file should have the same `source_context_list`, + # including the top-level one. + # Note: currently, non-top-level source contexts aren't necessarily required + # to be the same, but we set this requirement as it will be useful e.g. when + # we e.g. reuse a worker among multiple scenarios. + for scenario in scenarios: + assert (scenario['source_context_list'] == scenarios[0] + ['source_context_list']) + + # We process the top source context below, and do not include it in + # the JSON objects (i.e. `scenarios`) in generated HTML files. + top_source_context = scenarios[0]['source_context_list'].pop(0) + assert (top_source_context.source_context_type == 'top') + for scenario in scenarios[1:]: + assert (scenario['source_context_list'].pop(0) == top_source_context) + + parameters = {} + + # Sort scenarios, to avoid unnecessary diffs due to different orders in + # `scenarios`. + serialized_scenarios = sorted( + [dump_test_parameters(scenario) for scenario in scenarios]) + + parameters['scenarios'] = ",\n".join(serialized_scenarios).replace( + "\n", "\n" + " " * 10) + + test_directory = os.path.dirname(test_filename) + + parameters['helper_js'] = "" + for test_helper_filename in test_helper_filenames: + parameters['helper_js'] += ' \n' % ( + os.path.relpath(test_helper_filename, test_directory)) + parameters['sanity_checker_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'sanity-checker.js'), + test_directory) + parameters['spec_json_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'spec_json.js'), + test_directory) + + test_headers_filename = test_filename + ".headers" + + test_html_template = util.get_template(test_html_template_basename) + disclaimer_template = util.get_template('disclaimer.template') + + html_template_filename = os.path.join(util.template_directory, + test_html_template_basename) + generated_disclaimer = disclaimer_template \ + % {'generating_script_filename': os.path.relpath(sys.argv[0], + util.test_root_directory), + 'spec_directory': os.path.relpath(spec_directory, + util.test_root_directory)} + + # Adjust the template for the test invoking JS. Indent it to look nice. + parameters['generated_disclaimer'] = generated_disclaimer.rstrip() + + # Directory for the test files. + try: + os.makedirs(test_directory) + except: + pass + + delivery = handle_deliveries(top_source_context.policy_deliveries) + + if len(delivery['headers']) > 0: + with open(test_headers_filename, "w") as f: + for header in delivery['headers']: + f.write('%s: %s\n' % (header, delivery['headers'][header])) + + parameters['meta_delivery_method'] = delivery['meta'] + # Obey the lint and pretty format. + if len(parameters['meta_delivery_method']) > 0: + parameters['meta_delivery_method'] = "\n " + \ + parameters['meta_delivery_method'] + + # Write out the generated HTML file. + util.write_file(test_filename, test_html_template % parameters) + + +def generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, target): + test_expansion_schema = spec_json['test_expansion_schema'] + specification = spec_json['specification'] + + if target == "debug": + spec_json_js_template = util.get_template('spec_json.js.template') + util.write_file( + os.path.join(spec_directory, "generic", "spec_json.js"), + spec_json_js_template % {'spec_json': json.dumps(spec_json)}) + util.write_file( + os.path.join(spec_directory, "generic", + "debug-output.spec.src.json"), + json.dumps(spec_json, indent=2, separators=(',', ': '))) + + # Choose a debug/release template depending on the target. + html_template = "test.%s.html.template" % target + + artifact_order = test_expansion_schema.keys() + artifact_order.remove('expansion') + + excluded_selection_pattern = '' + for key in artifact_order: + excluded_selection_pattern += '%(' + key + ')s/' + + # Create list of excluded tests. + exclusion_dict = set() + for excluded_pattern in spec_json['excluded_tests']: + excluded_expansion = \ + expand_pattern(excluded_pattern, test_expansion_schema) + for excluded_selection in permute_expansion(excluded_expansion, + artifact_order): + excluded_selection['delivery_key'] = spec_json['delivery_key'] + exclusion_dict.add(excluded_selection_pattern % excluded_selection) + + # `scenarios[filename]` represents the list of scenario objects to be + # generated into `filename`. + scenarios = {} + + for spec in specification: + # Used to make entries with expansion="override" override preceding + # entries with the same |selection_path|. + output_dict = {} + + for expansion_pattern in spec['test_expansion']: + expansion = expand_pattern(expansion_pattern, + test_expansion_schema) + for selection in permute_expansion(expansion, artifact_order): + selection['delivery_key'] = spec_json['delivery_key'] + selection_path = spec_json['selection_pattern'] % selection + if selection_path in output_dict: + if expansion_pattern['expansion'] != 'override': + print("Error: expansion is default in:") + print(dump_test_parameters(selection)) + print("but overrides:") + print(dump_test_parameters( + output_dict[selection_path])) + sys.exit(1) + output_dict[selection_path] = copy.deepcopy(selection) + + for selection_path in output_dict: + selection = output_dict[selection_path] + if (excluded_selection_pattern % selection) in exclusion_dict: + print('Excluding selection:', selection_path) + continue + try: + test_filename = get_test_filename(spec_directory, spec_json, + selection) + scenario = generate_selection(spec_json, selection) + scenarios[test_filename] = scenarios.get(test_filename, + []) + [scenario] + except util.ShouldSkip: + continue + + for filename in scenarios: + generate_test_file(spec_directory, test_helper_filenames, + html_template, filename, scenarios[filename]) + + +def merge_json(base, child): + for key in child: + if key not in base: + base[key] = child[key] + continue + # `base[key]` and `child[key]` both exists. + if isinstance(base[key], list) and isinstance(child[key], list): + base[key].extend(child[key]) + elif isinstance(base[key], dict) and isinstance(child[key], dict): + merge_json(base[key], child[key]) + else: + base[key] = child[key] + + +def main(): + parser = argparse.ArgumentParser( + description='Test suite generator utility') + parser.add_argument( + '-t', + '--target', + type=str, + choices=("release", "debug"), + default="release", + help='Sets the appropriate template for generating tests') + parser.add_argument( + '-s', + '--spec', + type=str, + default=os.getcwd(), + help='Specify a file used for describing and generating the tests') + # TODO(kristijanburnik): Add option for the spec_json file. + args = parser.parse_args() + + spec_directory = os.path.abspath(args.spec) + + # Read `spec.src.json` files, starting from `spec_directory`, and + # continuing to parent directories as long as `spec.src.json` exists. + spec_filenames = [] + test_helper_filenames = [] + spec_src_directory = spec_directory + while len(spec_src_directory) >= len(util.test_root_directory): + spec_filename = os.path.join(spec_src_directory, "spec.src.json") + if not os.path.exists(spec_filename): + break + spec_filenames.append(spec_filename) + test_filename = os.path.join(spec_src_directory, 'generic', + 'test-case.sub.js') + assert (os.path.exists(test_filename)) + test_helper_filenames.append(test_filename) + spec_src_directory = os.path.abspath( + os.path.join(spec_src_directory, "..")) + + spec_filenames = list(reversed(spec_filenames)) + test_helper_filenames = list(reversed(test_helper_filenames)) + + if len(spec_filenames) == 0: + print('Error: No spec.src.json is found at %s.' % spec_directory) + return + + # Load the default spec JSON file, ... + default_spec_filename = os.path.join(util.script_directory, + 'spec.src.json') + spec_json = collections.OrderedDict() + if os.path.exists(default_spec_filename): + spec_json = util.load_spec_json(default_spec_filename) + + # ... and then make spec JSON files in subdirectories override the default. + for spec_filename in spec_filenames: + child_spec_json = util.load_spec_json(spec_filename) + merge_json(spec_json, child_spec_json) + + spec_validator.assert_valid_spec_json(spec_json) + generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, args.target) + + +if __name__ == '__main__': + main() diff --git a/test/wpt/tests/common/security-features/tools/spec.src.json b/test/wpt/tests/common/security-features/tools/spec.src.json new file mode 100644 index 0000000..4a84493 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/spec.src.json @@ -0,0 +1,533 @@ +{ + "selection_pattern": "%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s/%(origin)s.%(redirection)s.%(source_scheme)s", + "test_file_path_pattern": "gen/%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s.%(source_scheme)s.html", + "excluded_tests": [ + { + // Workers are same-origin only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": [ + "cross-https", + "cross-http", + "cross-http-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Workers are same-origin only (redirects) + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "swap-origin", + "swap-scheme" + ], + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": "*", + "expectation": "*" + }, + { + // Websockets are ws/wss-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": "websocket", + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade" + ], + "expectation": "*" + }, + { + // Redirects are intentionally forbidden in browsers: + // https://fetch.spec.whatwg.org/#concept-websocket-establish + // Websockets are no-redirect only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "subresource": "websocket", + "origin": "*", + "expectation": "*" + }, + { + // ws/wss are websocket-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "script-tag-dynamic-import", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ], + "origin": [ + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Worklets are HTTPS contexts only + "expansion": "*", + "source_scheme": "http", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data" + ], + "origin": "*", + "expectation": "*" + } + ], + "source_context_schema": { + "supported_subresource": { + "top": "*", + "iframe": "*", + "iframe-blank": "*", + "srcdoc": "*", + "worker-classic": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-module": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "worker-module-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module-data": [ + "xhr", + "fetch", + "websocket" + ] + } + }, + "source_context_list_schema": { + // Warning: Currently, some nested patterns of contexts have different + // inheritance rules for different kinds of policies. + // The generated tests will be used to test/investigate the policy + // inheritance rules, and eventually the policy inheritance rules will + // be unified (https://github.com/w3ctag/design-principles/issues/111). + "top": { + "description": "Policy set by the top-level Document", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "req": { + "description": "Subresource request's policy should override Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [ + "nonNullPolicy" + ] + }, + "srcdoc-inherit": { + "description": "srcdoc iframe without its own policy should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "srcdoc" + } + ], + "subresourcePolicyDeliveries": [] + }, + "srcdoc": { + "description": "srcdoc iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "srcdoc", + "policyDeliveries": [ + "nonNullPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe": { + "description": "external iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "iframe", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe-blank-inherit": { + "description": "blank iframe should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "iframe-blank" + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic": { + // This is applicable to referrer-policy tests. + // Use "worker-classic-inherit" for CSP (mixed-content, etc.). + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module": { + // This is applicable to referrer-policy tests. + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + } + }, + "test_expansion_schema": { + "expansion": [ + "default", + "override" + ], + "source_scheme": [ + "http", + "https" + ], + "source_context_list": [ + "top", + "req", + "srcdoc-inherit", + "srcdoc", + "iframe", + "iframe-blank-inherit", + "worker-classic", + "worker-classic-data", + "worker-module", + "worker-module-data", + "sharedworker-classic", + "sharedworker-classic-data", + "sharedworker-module", + "sharedworker-module-data" + ], + "redirection": [ + "no-redirect", + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade", + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "script-tag-dynamic-import", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "websocket", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ] + } +} diff --git a/test/wpt/tests/common/security-features/tools/spec_validator.py b/test/wpt/tests/common/security-features/tools/spec_validator.py new file mode 100644 index 0000000..f8a1390 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/spec_validator.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 + +import json, sys + + +def assert_non_empty_string(obj, field): + assert field in obj, 'Missing field "%s"' % field + assert isinstance(obj[field], basestring), \ + 'Field "%s" must be a string' % field + assert len(obj[field]) > 0, 'Field "%s" must not be empty' % field + + +def assert_non_empty_list(obj, field): + assert isinstance(obj[field], list), \ + '%s must be a list' % field + assert len(obj[field]) > 0, \ + '%s list must not be empty' % field + + +def assert_non_empty_dict(obj, field): + assert isinstance(obj[field], dict), \ + '%s must be a dict' % field + assert len(obj[field]) > 0, \ + '%s dict must not be empty' % field + + +def assert_contains(obj, field): + assert field in obj, 'Must contain field "%s"' % field + + +def assert_value_from(obj, field, items): + assert obj[field] in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_atom_or_list_items_from(obj, field, items): + if isinstance(obj[field], basestring) or isinstance( + obj[field], int) or obj[field] is None: + assert_value_from(obj, field, items) + return + + assert isinstance(obj[field], list), '%s must be a list' % field + for allowed_value in obj[field]: + assert allowed_value != '*', "Wildcard is not supported for lists!" + assert allowed_value in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_contains_only_fields(obj, expected_fields): + for expected_field in expected_fields: + assert_contains(obj, expected_field) + + for actual_field in obj: + assert actual_field in expected_fields, \ + 'Unexpected field "%s".' % actual_field + + +def leaf_values(schema): + if isinstance(schema, list): + return schema + ret = [] + for _, sub_schema in schema.iteritems(): + ret += leaf_values(sub_schema) + return ret + + +def assert_value_unique_in(value, used_values): + assert value not in used_values, 'Duplicate value "%s"!' % str(value) + used_values[value] = True + + +def assert_valid_artifact(exp_pattern, artifact_key, schema): + if isinstance(schema, list): + assert_atom_or_list_items_from(exp_pattern, artifact_key, + ["*"] + schema) + return + + for sub_artifact_key, sub_schema in schema.iteritems(): + assert_valid_artifact(exp_pattern[artifact_key], sub_artifact_key, + sub_schema) + + +def validate(spec_json, details): + """ Validates the json specification for generating tests. """ + + details['object'] = spec_json + assert_contains_only_fields(spec_json, [ + "selection_pattern", "test_file_path_pattern", + "test_description_template", "test_page_title_template", + "specification", "delivery_key", "subresource_schema", + "source_context_schema", "source_context_list_schema", + "test_expansion_schema", "excluded_tests" + ]) + assert_non_empty_list(spec_json, "specification") + assert_non_empty_dict(spec_json, "test_expansion_schema") + assert_non_empty_list(spec_json, "excluded_tests") + + specification = spec_json['specification'] + test_expansion_schema = spec_json['test_expansion_schema'] + excluded_tests = spec_json['excluded_tests'] + + valid_test_expansion_fields = test_expansion_schema.keys() + + # Should be consistent with `sourceContextMap` in + # `/common/security-features/resources/common.sub.js`. + valid_source_context_names = [ + "top", "iframe", "iframe-blank", "srcdoc", "worker-classic", + "worker-module", "worker-classic-data", "worker-module-data", + "sharedworker-classic", "sharedworker-module", + "sharedworker-classic-data", "sharedworker-module-data" + ] + + valid_subresource_names = [ + "a-tag", "area-tag", "audio-tag", "form-tag", "iframe-tag", "img-tag", + "link-css-tag", "link-prefetch-tag", "object-tag", "picture-tag", + "script-tag", "script-tag-dynamic-import", "video-tag" + ] + ["beacon", "fetch", "xhr", "websocket"] + [ + "worker-classic", "worker-module", "worker-import", + "worker-import-data", "sharedworker-classic", "sharedworker-module", + "sharedworker-import", "sharedworker-import-data", + "serviceworker-classic", "serviceworker-module", + "serviceworker-import", "serviceworker-import-data" + ] + [ + "worklet-animation", "worklet-audio", "worklet-layout", + "worklet-paint", "worklet-animation-import", "worklet-audio-import", + "worklet-layout-import", "worklet-paint-import", + "worklet-animation-import-data", "worklet-audio-import-data", + "worklet-layout-import-data", "worklet-paint-import-data" + ] + + # Validate each single spec. + for spec in specification: + details['object'] = spec + + # Validate required fields for a single spec. + assert_contains_only_fields(spec, [ + 'title', 'description', 'specification_url', 'test_expansion' + ]) + assert_non_empty_string(spec, 'title') + assert_non_empty_string(spec, 'description') + assert_non_empty_string(spec, 'specification_url') + assert_non_empty_list(spec, 'test_expansion') + + for spec_exp in spec['test_expansion']: + details['object'] = spec_exp + assert_contains_only_fields(spec_exp, valid_test_expansion_fields) + + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(spec_exp, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + # Validate source_context_schema. + details['object'] = spec_json['source_context_schema'] + assert_contains_only_fields( + spec_json['source_context_schema'], + ['supported_delivery_type', 'supported_subresource']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_delivery_type'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_delivery_type'], + source_context, test_expansion_schema['delivery_type']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_subresource'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_subresource']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_subresource'], + source_context, leaf_values(test_expansion_schema['subresource'])) + + # Validate subresource_schema. + details['object'] = spec_json['subresource_schema'] + assert_contains_only_fields(spec_json['subresource_schema'], + ['supported_delivery_type']) + assert_contains_only_fields( + spec_json['subresource_schema']['supported_delivery_type'], + leaf_values(test_expansion_schema['subresource'])) + for subresource in spec_json['subresource_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['subresource_schema']['supported_delivery_type'], + subresource, test_expansion_schema['delivery_type']) + + # Validate the test_expansion schema members. + details['object'] = test_expansion_schema + assert_contains_only_fields(test_expansion_schema, [ + 'expansion', 'source_scheme', 'source_context_list', 'delivery_type', + 'delivery_value', 'redirection', 'subresource', 'origin', 'expectation' + ]) + assert_atom_or_list_items_from(test_expansion_schema, 'expansion', + ['default', 'override']) + assert_atom_or_list_items_from(test_expansion_schema, 'source_scheme', + ['http', 'https']) + assert_atom_or_list_items_from( + test_expansion_schema, 'source_context_list', + spec_json['source_context_list_schema'].keys()) + + # Should be consistent with `preprocess_redirection` in + # `/common/security-features/subresource/subresource.py`. + assert_atom_or_list_items_from(test_expansion_schema, 'redirection', [ + 'no-redirect', 'keep-origin', 'swap-origin', 'keep-scheme', + 'swap-scheme', 'downgrade' + ]) + for subresource in leaf_values(test_expansion_schema['subresource']): + assert subresource in valid_subresource_names, "Invalid subresource %s" % subresource + # Should be consistent with getSubresourceOrigin() in + # `/common/security-features/resources/common.sub.js`. + assert_atom_or_list_items_from(test_expansion_schema, 'origin', [ + 'same-http', 'same-https', 'same-ws', 'same-wss', 'cross-http', + 'cross-https', 'cross-ws', 'cross-wss', 'same-http-downgrade', + 'cross-http-downgrade', 'same-ws-downgrade', 'cross-ws-downgrade' + ]) + + # Validate excluded tests. + details['object'] = excluded_tests + for excluded_test_expansion in excluded_tests: + assert_contains_only_fields(excluded_test_expansion, + valid_test_expansion_fields) + details['object'] = excluded_test_expansion + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(excluded_test_expansion, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + del details['object'] + + +def assert_valid_spec_json(spec_json): + error_details = {} + try: + validate(spec_json, error_details) + except AssertionError as err: + print('ERROR:', err.message) + print(json.dumps(error_details, indent=4)) + sys.exit(1) + + +def main(): + spec_json = load_spec_json() + assert_valid_spec_json(spec_json) + print("Spec JSON is valid.") + + +if __name__ == '__main__': + main() diff --git a/test/wpt/tests/common/security-features/tools/template/disclaimer.template b/test/wpt/tests/common/security-features/tools/template/disclaimer.template new file mode 100644 index 0000000..ba9458c --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/template/disclaimer.template @@ -0,0 +1 @@ + diff --git a/test/wpt/tests/common/security-features/tools/template/spec_json.js.template b/test/wpt/tests/common/security-features/tools/template/spec_json.js.template new file mode 100644 index 0000000..e4cbd03 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/template/spec_json.js.template @@ -0,0 +1 @@ +var SPEC_JSON = %(spec_json)s; diff --git a/test/wpt/tests/common/security-features/tools/template/test.debug.html.template b/test/wpt/tests/common/security-features/tools/template/test.debug.html.template new file mode 100644 index 0000000..b6be088 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/template/test.debug.html.template @@ -0,0 +1,26 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + + + + + +%(helper_js)s + + +
+ + diff --git a/test/wpt/tests/common/security-features/tools/template/test.release.html.template b/test/wpt/tests/common/security-features/tools/template/test.release.html.template new file mode 100644 index 0000000..bac2d5b --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/template/test.release.html.template @@ -0,0 +1,22 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + +%(helper_js)s + + +
+ + diff --git a/test/wpt/tests/common/security-features/tools/util.py b/test/wpt/tests/common/security-features/tools/util.py new file mode 100644 index 0000000..5da06f9 --- /dev/null +++ b/test/wpt/tests/common/security-features/tools/util.py @@ -0,0 +1,228 @@ +import os, sys, json, json5, re +import collections + +script_directory = os.path.dirname(os.path.abspath(__file__)) +template_directory = os.path.abspath( + os.path.join(script_directory, 'template')) +test_root_directory = os.path.abspath( + os.path.join(script_directory, '..', '..', '..')) + + +def get_template(basename): + with open(os.path.join(template_directory, basename), "r") as f: + return f.read() + + +def write_file(filename, contents): + with open(filename, "w") as f: + f.write(contents) + + +def read_nth_line(fp, line_number): + fp.seek(0) + for i, line in enumerate(fp): + if (i + 1) == line_number: + return line + + +def load_spec_json(path_to_spec): + re_error_location = re.compile('line ([0-9]+) column ([0-9]+)') + with open(path_to_spec, "r") as f: + try: + return json5.load(f, object_pairs_hook=collections.OrderedDict) + except ValueError as ex: + print(ex.message) + match = re_error_location.search(ex.message) + if match: + line_number, column = int(match.group(1)), int(match.group(2)) + print(read_nth_line(f, line_number).rstrip()) + print(" " * (column - 1) + "^") + sys.exit(1) + + +class ShouldSkip(Exception): + ''' + Raised when the given combination of subresource type, source context type, + delivery type etc. are not supported and we should skip that configuration. + ShouldSkip is expected in normal generator execution (and thus subsequent + generation continues), as we first enumerate a broad range of configurations + first, and later raise ShouldSkip to filter out unsupported combinations. + + ShouldSkip is distinguished from other general errors that cause immediate + termination of the generator and require fix. + ''' + def __init__(self): + pass + + +class PolicyDelivery(object): + ''' + See `@typedef PolicyDelivery` comments in + `common/security-features/resources/common.sub.js`. + ''' + + def __init__(self, delivery_type, key, value): + self.delivery_type = delivery_type + self.key = key + self.value = value + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def list_from_json(cls, list, target_policy_delivery, + supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> typing.List[PolicyDelivery] + ''' + Parses a JSON object `list` that represents a list of `PolicyDelivery` + and returns a list of `PolicyDelivery`, plus supporting placeholders + (see `from_json()` comments below or + `common/security-features/README.md`). + + Can raise `ShouldSkip`. + ''' + if list is None: + return [] + + out = [] + for obj in list: + policy_delivery = PolicyDelivery.from_json( + obj, target_policy_delivery, supported_delivery_types) + # Drop entries with null values. + if policy_delivery.value is None: + continue + out.append(policy_delivery) + return out + + @classmethod + def from_json(cls, obj, target_policy_delivery, supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> PolicyDelivery + ''' + Parses a JSON object `obj` and returns a `PolicyDelivery` object. + In addition to dicts (in the same format as to_json() outputs), + this method accepts the following placeholders: + "policy": + `target_policy_delivery` + "policyIfNonNull": + `target_policy_delivery` if its value is not None. + "anotherPolicy": + A PolicyDelivery that has the same key as + `target_policy_delivery` but a different value. + The delivery type is selected from `supported_delivery_types`. + + Can raise `ShouldSkip`. + ''' + + if obj == "policy": + policy_delivery = target_policy_delivery + elif obj == "nonNullPolicy": + if target_policy_delivery.value is None: + raise ShouldSkip() + policy_delivery = target_policy_delivery + elif obj == "anotherPolicy": + if len(supported_delivery_types) == 0: + raise ShouldSkip() + policy_delivery = target_policy_delivery.get_another_policy( + supported_delivery_types[0]) + elif isinstance(obj, dict): + policy_delivery = PolicyDelivery(obj['deliveryType'], obj['key'], + obj['value']) + else: + raise Exception('policy delivery is invalid: ' + obj) + + # Omit unsupported combinations of source contexts and delivery type. + if policy_delivery.delivery_type not in supported_delivery_types: + raise ShouldSkip() + + return policy_delivery + + def to_json(self): + # type: () -> dict + return { + "deliveryType": self.delivery_type, + "key": self.key, + "value": self.value + } + + def get_another_policy(self, delivery_type): + # type: (str) -> PolicyDelivery + if self.key == 'referrerPolicy': + # Return 'unsafe-url' (i.e. more unsafe policy than `self.value`) + # as long as possible, to make sure the tests to fail if the + # returned policy is used unexpectedly instead of `self.value`. + # Using safer policy wouldn't be distinguishable from acceptable + # arbitrary policy enforcement by user agents, as specified at + # Step 7 of + # https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer: + # "The user agent MAY alter referrerURL or referrerOrigin at this + # point to enforce arbitrary policy considerations in the + # interests of minimizing data leakage." + # See also the comments at `referrerUrlResolver` in + # `wpt/referrer-policy/generic/test-case.sub.js`. + if self.value != 'unsafe-url': + return PolicyDelivery(delivery_type, self.key, 'unsafe-url') + else: + return PolicyDelivery(delivery_type, self.key, 'no-referrer') + elif self.key == 'mixedContent': + if self.value == 'opt-in': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'opt-in') + elif self.key == 'contentSecurityPolicy': + if self.value is not None: + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'worker-src-none') + elif self.key == 'upgradeInsecureRequests': + if self.value == 'upgrade': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'upgrade') + else: + raise Exception('delivery key is invalid: ' + self.key) + + +class SourceContext(object): + def __init__(self, source_context_type, policy_deliveries): + # type: (unicode, typing.List[PolicyDelivery]) -> None + self.source_context_type = source_context_type + self.policy_deliveries = policy_deliveries + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def from_json(cls, obj, target_policy_delivery, source_context_schema): + ''' + Parses a JSON object `obj` and returns a `SourceContext` object. + + `target_policy_delivery` and `source_context_schema` are used for + policy delivery placeholders and filtering out unsupported + delivery types. + + Can raise `ShouldSkip`. + ''' + source_context_type = obj.get('sourceContextType') + policy_deliveries = PolicyDelivery.list_from_json( + obj.get('policyDeliveries'), target_policy_delivery, + source_context_schema['supported_delivery_type'] + [source_context_type]) + return SourceContext(source_context_type, policy_deliveries) + + def to_json(self): + return { + "sourceContextType": self.source_context_type, + "policyDeliveries": [x.to_json() for x in self.policy_deliveries] + } + + +class CustomEncoder(json.JSONEncoder): + ''' + Used to dump dicts containing `SourceContext`/`PolicyDelivery` into JSON. + ''' + def default(self, obj): + if isinstance(obj, SourceContext): + return obj.to_json() + if isinstance(obj, PolicyDelivery): + return obj.to_json() + return json.JSONEncoder.default(self, obj) diff --git a/test/wpt/tests/common/security-features/types.md b/test/wpt/tests/common/security-features/types.md new file mode 100644 index 0000000..1707991 --- /dev/null +++ b/test/wpt/tests/common/security-features/types.md @@ -0,0 +1,62 @@ +# Types around the generator and generated tests + +This document describes types and concepts used across JavaScript and Python parts of this test framework. +Please refer to the JSDoc in `common.sub.js` or docstrings in Python scripts (if any). + +## Scenario + +### Properties + +- All keys of `test_expansion_schema` in `spec.src.json`, except for `expansion`, `delivery_type`, `delivery_value`, and `source_context_list`. Their values are **string**s specified in `test_expansion_schema`. +- `source_context_list` +- `subresource_policy_deliveries` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `dict` +- Runtime (JS): JSON object +- Runtime (Python): N/A + +## `PolicyDelivery` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `util.PolicyDelivery` +- Runtime (JS): JSON object (`@typedef PolicyDelivery` in `common.sub.js`) +- Runtime (Python): N/A + +## `SourceContext` + +Subresource requests can be possibly sent from various kinds of fetch client's environment settings objects. For example: + +- top-level windows, +- ` diff --git a/test/wpt/tests/fetch/api/abort/general.any.js b/test/wpt/tests/fetch/api/abort/general.any.js new file mode 100644 index 0000000..3727bb4 --- /dev/null +++ b/test/wpt/tests/fetch/api/abort/general.any.js @@ -0,0 +1,572 @@ +// META: timeout=long +// META: global=window,worker +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../request/request-error.js + +const BODY_METHODS = ['arrayBuffer', 'blob', 'formData', 'json', 'text']; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +// This is used to close connections that weren't correctly closed during the tests, +// otherwise you can end up running out of HTTP connections. +let requestAbortKeys = []; + +function abortRequests() { + const keys = requestAbortKeys; + requestAbortKeys = []; + return Promise.all( + keys.map(key => fetch(`../resources/stash-put.py?key=${key}&value=close`)) + ); +} + +const hostInfo = get_host_info(); +const urlHostname = hostInfo.REMOTE_HOST; + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetchPromise = fetch('../resources/data.json', { signal }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(error1); + + const fetchPromise = fetch('../resources/data.json', { signal }); + + await promise_rejects_exactly(t, error1, fetchPromise, 'fetch() should reject with abort reason'); +}, "Aborting rejects with abort reason"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const url = new URL('../resources/data.json', location); + url.hostname = urlHostname; + + const fetchPromise = fetch(url, { + signal, + mode: 'no-cors' + }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError - no-cors"); + +// Test that errors thrown from the request constructor take priority over abort errors. +// badRequestArgTests is from response-error.js +for (const { args, testName } of badRequestArgTests) { + promise_test(async t => { + try { + // If this doesn't throw, we'll effectively skip the test. + // It'll fail properly in ../request/request-error.html + new Request(...args); + } + catch (err) { + const controller = new AbortController(); + controller.abort(); + + // Add signal to 2nd arg + args[1] = args[1] || {}; + args[1].signal = controller.signal; + await promise_rejects_js(t, TypeError, fetch(...args)); + } + }, `TypeError from request constructor takes priority - ${testName}`); +} + +test(() => { + const request = new Request(''); + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); +}, "Request objects have a signal property"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); + assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference'); + assert_true(request.signal.aborted, `Request's signal has aborted`); + + const fetchPromise = fetch(request); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(error1); + + const request = new Request('../resources/data.json', { signal }); + + assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference'); + assert_true(request.signal.aborted, `Request's signal has aborted`); + assert_equals(request.signal.reason, error1, `Request's signal's abort reason is error1`); + + const fetchPromise = fetch(request); + + await promise_rejects_exactly(t, error1, fetchPromise, "fetch() should reject with abort reason"); +}, "Signal on request object should also have abort reason"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + const requestFromRequest = new Request(request); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json'); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal: new AbortController().signal }); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request overriding another"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const fetchPromise = fetch(request, {method: 'POST'}); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal retained after unrelated properties are overridden by fetch"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const data = await fetch(request, { signal: null }).then(r => r.json()); + assert_equals(data.key, 'value', 'Fetch fully completes'); +}, "Signal removed by setting to null"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const log = []; + + await Promise.all([ + fetch('../resources/data.json', { signal }).then( + () => assert_unreached("Fetch must not resolve"), + () => log.push('fetch-reject') + ), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + assert_array_equals(log, ['fetch-reject', 'next-microtask']); +}, "Already aborted signal rejects immediately"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { + signal, + method: 'POST', + body: 'foo', + headers: { 'Content-Type': 'text/plain' } + }); + + await fetch(request).catch(() => {}); + + assert_true(request.bodyUsed, "Body has been used"); +}, "Request is still 'used' if signal is aborted before fetching"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + + const log = []; + const response = await fetch('../resources/data.json', { signal }); + + controller.abort(); + + const bodyPromise = response[bodyMethod](); + + await Promise.all([ + bodyPromise.catch(() => log.push(`${bodyMethod}-reject`)), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + assert_array_equals(log, [`${bodyMethod}-reject`, 'next-microtask']); + }, `response.${bodyMethod}() rejects if already aborted`); +} + +promise_test(async (t) => { + const controller = new AbortController(); + const signal = controller.signal; + + const res = await fetch('../resources/data.json', { signal }); + controller.abort(); + + await promise_rejects_dom(t, 'AbortError', res.text()); + await promise_rejects_dom(t, 'AbortError', res.text()); +}, 'Call text() twice on aborted response'); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + controller.abort(); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }).catch(() => {}); + + // I'm hoping this will give the browser enough time to (incorrectly) make the request + // above, if it intends to. + await fetch('../resources/data.json').then(r => r.json()); + + const response = await fetch(`../resources/stash-take.py?key=${stateKey}`); + const data = await response.json(); + + assert_equals(data, null, "Request hasn't been made to the server"); +}, "Already aborted signal does not make request"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Already aborted signal can be used for many fetches"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + await fetch('../resources/data.json', { signal }).then(r => r.json()); + + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Signal can be used to abort other fetches, even if another fetch succeeded before aborting"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const url = new URL(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, location); + url.hostname = urlHostname; + + await fetch(url, { + signal, + mode: 'no-cors' + }); + + const stashTakeURL = new URL(`../resources/stash-take.py?key=${stateKey}`, location); + stashTakeURL.hostname = urlHostname; + + const beforeAbortResult = await fetch(stashTakeURL).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(stashTakeURL).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response - no-cors"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + const bodyPromise = response[bodyMethod](); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } + }, `Fetch aborted & connection closed when aborted after calling response.${bodyMethod}()`); +} + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + await reader.read(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted, after reading. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + const response = await fetch(`../resources/empty.txt`, { signal }); + + // Read whole response to ensure close signal has sent. + await response.clone().text(); + + const reader = response.body.getReader(); + + controller.abort(); + + const item = await reader.read(); + + assert_true(item.done, "Stream is done"); +}, "Stream will not error if body is empty. It's closed with an empty queue before it errors."); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + let cancelReason; + + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([42])); + }, + cancel(reason) { + cancelReason = reason; + } + }); + + const fetchPromise = fetch('../resources/empty.txt', { + body, signal, + method: 'POST', + duplex: 'half', + headers: { + 'Content-Type': 'text/plain' + } + }); + + assert_true(!!cancelReason, 'Cancel called sync'); + assert_equals(cancelReason.constructor, DOMException); + assert_equals(cancelReason.name, 'AbortError'); + + await promise_rejects_dom(t, "AbortError", fetchPromise); + + const fetchErr = await fetchPromise.catch(e => e); + + assert_equals(cancelReason, fetchErr, "Fetch rejects with same error instance"); +}, "Readable stream synchronously cancels with AbortError if aborted before reading"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('.', { signal }); + const requestSignal = request.signal; + + const clonedRequest = request.clone(); + + assert_equals(requestSignal, request.signal, "Original request signal the same after cloning"); + assert_true(request.signal.aborted, "Original request signal aborted"); + assert_not_equals(clonedRequest.signal, request.signal, "Cloned request has different signal"); + assert_true(clonedRequest.signal.aborted, "Cloned request signal aborted"); +}, "Signal state is cloned"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + + const request = new Request('.', { signal }); + const clonedRequest = request.clone(); + + const log = []; + + request.signal.addEventListener('abort', () => log.push('original-aborted')); + clonedRequest.signal.addEventListener('abort', () => log.push('clone-aborted')); + + controller.abort(); + + assert_array_equals(log, ['original-aborted', 'clone-aborted'], "Abort events fired in correct order"); + assert_true(request.signal.aborted, 'Signal aborted'); + assert_true(clonedRequest.signal.aborted, 'Signal aborted'); +}, "Clone aborts with original controller"); diff --git a/test/wpt/tests/fetch/api/abort/keepalive.html b/test/wpt/tests/fetch/api/abort/keepalive.html new file mode 100644 index 0000000..db12df0 --- /dev/null +++ b/test/wpt/tests/fetch/api/abort/keepalive.html @@ -0,0 +1,85 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/api/abort/request.any.js b/test/wpt/tests/fetch/api/abort/request.any.js new file mode 100644 index 0000000..dcc7803 --- /dev/null +++ b/test/wpt/tests/fetch/api/abort/request.any.js @@ -0,0 +1,85 @@ +// META: timeout=long +// META: global=window,worker + +const BODY_FUNCTION_AND_DATA = { + arrayBuffer: null, + blob: null, + formData: new FormData(), + json: new Blob(["{}"]), + text: null, +}; + +for (const [bodyFunction, body] of Object.entries(BODY_FUNCTION_AND_DATA)) { + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + controller.abort(); + await request[bodyFunction](); + assert_true( + true, + `An aborted request should still be able to run ${bodyFunction}()` + ); + }, `Calling ${bodyFunction}() on an aborted request`); + + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + const p = request[bodyFunction](); + controller.abort(); + await p; + assert_true( + true, + `An aborted request should still be able to run ${bodyFunction}()` + ); + }, `Aborting a request after calling ${bodyFunction}()`); + + if (!body) { + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + // consuming happens synchronously, so don't wait + fetch(request).catch(() => {}); + + controller.abort(); + await request[bodyFunction](); + assert_true( + true, + `An aborted consumed request should still be able to run ${bodyFunction}() when empty` + ); + }, `Calling ${bodyFunction}() on an aborted consumed empty request`); + } + + promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body: body || new Blob(["foo"]), + }); + + // consuming happens synchronously, so don't wait + fetch(request).catch(() => {}); + + controller.abort(); + await promise_rejects_js(t, TypeError, request[bodyFunction]()); + }, `Calling ${bodyFunction}() on an aborted consumed nonempty request`); +} diff --git a/test/wpt/tests/fetch/api/abort/serviceworker-intercepted.https.html b/test/wpt/tests/fetch/api/abort/serviceworker-intercepted.https.html new file mode 100644 index 0000000..ed9bc97 --- /dev/null +++ b/test/wpt/tests/fetch/api/abort/serviceworker-intercepted.https.html @@ -0,0 +1,212 @@ + + + + + Aborting fetch when intercepted by a service worker + + + + + + + + diff --git a/test/wpt/tests/fetch/api/basic/accept-header.any.js b/test/wpt/tests/fetch/api/basic/accept-header.any.js new file mode 100644 index 0000000..cd54cf2 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/accept-header.any.js @@ -0,0 +1,34 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept").then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept"), "*/*", "Request has accept header with value '*/*'"); + }); +}, "Request through fetch should have 'accept' header with value '*/*'"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept", {"headers": [["Accept", "custom/*"]]}).then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept"), "custom/*", "Request has accept header with value 'custom/*'"); + }); +}, "Request through fetch should have 'accept' header with value 'custom/*'"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept-Language").then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_true(response.headers.has("x-request-accept-language")); + }); +}, "Request through fetch should have a 'accept-language' header"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept-Language", {"headers": [["Accept-Language", "bzh"]]}).then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept-language"), "bzh", "Request has accept header with value 'bzh'"); + }); +}, "Request through fetch should have 'accept-language' header with value 'bzh'"); diff --git a/test/wpt/tests/fetch/api/basic/block-mime-as-script.html b/test/wpt/tests/fetch/api/basic/block-mime-as-script.html new file mode 100644 index 0000000..afc2bbb --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/block-mime-as-script.html @@ -0,0 +1,43 @@ + + +Block mime type as script + + +
+ diff --git a/test/wpt/tests/fetch/api/basic/conditional-get.any.js b/test/wpt/tests/fetch/api/basic/conditional-get.any.js new file mode 100644 index 0000000..2f9fa81 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/conditional-get.any.js @@ -0,0 +1,38 @@ +// META: title=Request ETag +// META: global=window,worker +// META: script=/common/utils.js + +promise_test(function() { + var cacheBuster = token(); // ensures first request is uncached + var url = "../resources/cache.py?v=" + cacheBuster; + var etag; + + // make the first request + return fetch(url).then(function(response) { + // ensure we're getting the regular, uncached response + assert_equals(response.status, 200); + assert_equals(response.headers.get("X-HTTP-STATUS"), null) + + return response.text(); // consuming the body, just to be safe + }).then(function(body) { + // make a second request + return fetch(url); + }).then(function(response) { + // while the server responds with 304 if our browser sent the correct + // If-None-Match request header, at the JavaScript level this surfaces + // as 200 + assert_equals(response.status, 200); + assert_equals(response.headers.get("X-HTTP-STATUS"), "304") + + etag = response.headers.get("ETag") + + return response.text(); // consuming the body, just to be safe + }).then(function(body) { + // make a third request, explicitly setting If-None-Match request header + var headers = { "If-None-Match": etag } + return fetch(url, { headers: headers }) + }).then(function(response) { + // 304 now surfaces thanks to the explicit If-None-Match request header + assert_equals(response.status, 304); + }); +}, "Testing conditional GET with ETags"); diff --git a/test/wpt/tests/fetch/api/basic/error-after-response.any.js b/test/wpt/tests/fetch/api/basic/error-after-response.any.js new file mode 100644 index 0000000..f711442 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/error-after-response.any.js @@ -0,0 +1,24 @@ +// META: title=Fetch: network timeout after receiving the HTTP response headers +// META: global=window,worker +// META: timeout=long +// META: script=../resources/utils.js + +function checkReader(test, reader, promiseToTest) +{ + return reader.read().then((value) => { + validateBufferFromString(value.value, "TEST_CHUNK", "Should receive first chunk"); + return promise_rejects_js(test, TypeError, promiseToTest(reader)); + }); +} + +promise_test((test) => { + return fetch("../resources/bad-chunk-encoding.py?count=1").then((response) => { + return checkReader(test, response.body.getReader(), reader => reader.read()); + }); +}, "Response reader read() promise should reject after a network error happening after resolving fetch promise"); + +promise_test((test) => { + return fetch("../resources/bad-chunk-encoding.py?count=1").then((response) => { + return checkReader(test, response.body.getReader(), reader => reader.closed); + }); +}, "Response reader closed promise should reject after a network error happening after resolving fetch promise"); diff --git a/test/wpt/tests/fetch/api/basic/header-value-combining.any.js b/test/wpt/tests/fetch/api/basic/header-value-combining.any.js new file mode 100644 index 0000000..bb70d87 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/header-value-combining.any.js @@ -0,0 +1,15 @@ +// META: global=window,worker + +[ + ["content-length", "0", "header-content-length"], + ["content-length", "0, 0", "header-content-length-twice"], + ["double-trouble", ", ", "headers-double-empty"], + ["foo-test", "1, 2, 3", "headers-basic"], + ["heya", ", \u000B\u000C, 1, , , 2", "headers-some-are-empty"], + ["www-authenticate", "1, 2, 3, 4", "headers-www-authenticate"], +].forEach(testValues => { + promise_test(async t => { + const response = await fetch("../../../xhr/resources/" + testValues[2] + ".asis"); + assert_equals(response.headers.get(testValues[0]), testValues[1]); + }, "response.headers.get('" + testValues[0] + "') expects " + testValues[1]); +}); diff --git a/test/wpt/tests/fetch/api/basic/header-value-null-byte.any.js b/test/wpt/tests/fetch/api/basic/header-value-null-byte.any.js new file mode 100644 index 0000000..741d83b --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/header-value-null-byte.any.js @@ -0,0 +1,5 @@ +// META: global=window,worker + +promise_test(t => { + return promise_rejects_js(t, TypeError, fetch("../../../xhr/resources/parse-headers.py?my-custom-header="+encodeURIComponent("x\0x"))); +}, "Ensure fetch() rejects null bytes in headers"); diff --git a/test/wpt/tests/fetch/api/basic/historical.any.js b/test/wpt/tests/fetch/api/basic/historical.any.js new file mode 100644 index 0000000..c808126 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/historical.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +test(() => { + assert_false("getAll" in new Headers()); + assert_false("getAll" in Headers.prototype); +}, "Headers object no longer has a getAll() method"); + +test(() => { + assert_false("type" in new Request("about:blank")); + assert_false("type" in Request.prototype); +}, "'type' getter should not exist on Request objects"); + +// See https://github.com/whatwg/fetch/pull/979 for the removal +test(() => { + assert_false("trailer" in new Response()); + assert_false("trailer" in Response.prototype); +}, "Response object no longer has a trailer getter"); diff --git a/test/wpt/tests/fetch/api/basic/http-response-code.any.js b/test/wpt/tests/fetch/api/basic/http-response-code.any.js new file mode 100644 index 0000000..1fd312a --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/http-response-code.any.js @@ -0,0 +1,14 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +promise_test(async (test) => { + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=425&uuid=${token()}&partition_id=${get_host_info().ORIGIN}` + + `&dispatch=check_partition&addcounter=true`); + assert_equals(resp.status, 425); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 1 times. 1 connections were created."); +}, "Fetch on 425 response should not be retried for non TLS early data."); diff --git a/test/wpt/tests/fetch/api/basic/integrity.sub.any.js b/test/wpt/tests/fetch/api/basic/integrity.sub.any.js new file mode 100644 index 0000000..e3cfd1b --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/integrity.sub.any.js @@ -0,0 +1,87 @@ +// META: global=window,dedicatedworker,sharedworker +// META: script=../resources/utils.js + +function integrity(desc, url, integrity, initRequestMode, shouldPass) { + var fetchRequestInit = {'integrity': integrity} + if (!!initRequestMode && initRequestMode !== "") { + fetchRequestInit.mode = initRequestMode; + } + + if (shouldPass) { + promise_test(function(test) { + return fetch(url, fetchRequestInit).then(function(resp) { + if (initRequestMode !== "no-cors") { + assert_equals(resp.status, 200, "Response's status is 200"); + } else { + assert_equals(resp.status, 0, "Opaque response's status is 0"); + assert_equals(resp.type, "opaque"); + } + }); + }, desc); + } else { + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, fetchRequestInit)); + }, desc); + } +} + +const topSha256 = "sha256-KHIDZcXnR2oBHk9DrAA+5fFiR6JjudYjqoXtMR1zvzk="; +const topSha384 = "sha384-MgZYnnAzPM/MjhqfOIMfQK5qcFvGZsGLzx4Phd7/A8fHTqqLqXqKo8cNzY3xEPTL"; +const topSha512 = "sha512-D6yns0qxG0E7+TwkevZ4Jt5t7Iy3ugmAajG/dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg=="; +const topSha512wrongpadding = "sha512-D6yns0qxG0E7+TwkevZ4Jt5t7Iy3ugmAajG/dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg"; +const topSha512base64url = "sha512-D6yns0qxG0E7-TwkevZ4Jt5t7Iy3ugmAajG_dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg=="; +const topSha512base64url_nopadding = "sha512-D6yns0qxG0E7-TwkevZ4Jt5t7Iy3ugmAajG_dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg"; +const invalidSha256 = "sha256-dKUcPOn/AlUjWIwcHeHNqYXPlvyGiq+2dWOdFcE+24I="; +const invalidSha512 = "sha512-oUceBRNxPxnY60g/VtPCj2syT4wo4EZh2CgYdWy9veW8+OsReTXoh7dizMGZafvx9+QhMS39L/gIkxnPIn41Zg=="; + +const path = dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +const url = path; +const corsUrl = + `http://{{host}}:{{ports[http][1]}}${path}?pipe=header(Access-Control-Allow-Origin,*)`; +const corsUrl2 = `https://{{host}}:{{ports[https][0]}}${path}` + +integrity("Empty string integrity", url, "", /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-256 integrity", url, topSha256, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-384 integrity", url, topSha384, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-512 integrity", url, topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-512 integrity with missing padding", url, topSha512wrongpadding, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("SHA-512 integrity base64url encoded", url, topSha512base64url, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("SHA-512 integrity base64url encoded with missing padding", url, + topSha512base64url_nopadding, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Invalid integrity", url, invalidSha256, + /* initRequestMode */ undefined, /* shouldPass */ false); +integrity("Multiple integrities: valid stronger than invalid", url, + invalidSha256 + " " + topSha384, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: invalid stronger than valid", + url, invalidSha512 + " " + topSha384, /* initRequestMode */ undefined, + /* shouldPass */ false); +integrity("Multiple integrities: invalid as strong as valid", url, + invalidSha512 + " " + topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: both are valid", url, + topSha384 + " " + topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: both are invalid", url, + invalidSha256 + " " + invalidSha512, /* initRequestMode */ undefined, + /* shouldPass */ false); +integrity("CORS empty integrity", corsUrl, "", /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("CORS SHA-512 integrity", corsUrl, topSha512, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("CORS invalid integrity", corsUrl, invalidSha512, + /* initRequestMode */ undefined, /* shouldPass */ false); + +integrity("Empty string integrity for opaque response", corsUrl2, "", + /* initRequestMode */ "no-cors", /* shouldPass */ true); +integrity("SHA-* integrity for opaque response", corsUrl2, topSha512, + /* initRequestMode */ "no-cors", /* shouldPass */ false); + +done(); diff --git a/test/wpt/tests/fetch/api/basic/keepalive.any.js b/test/wpt/tests/fetch/api/basic/keepalive.any.js new file mode 100644 index 0000000..899d41d --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/keepalive.any.js @@ -0,0 +1,43 @@ +// META: global=window +// META: title=Fetch API: keepalive handling +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +} = get_host_info(); + +/** + * In a different-site iframe, test to fetch a keepalive URL on the specified + * document event. + */ +function keepaliveSimpleRequestTest(method) { + for (const evt of ['load', 'pagehide', 'unload']) { + const desc = + `[keepalive] simple ${method} request on '${evt}' [no payload]`; + promise_test(async (test) => { + const token1 = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveIframeUrl(token1, method, {sendOn: evt}); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + if (evt != 'load') { + iframe.remove(); + } + assert_equals(await getTokenFromMessage(), token1); + + assertStashedTokenAsync(desc, token1); + }, `${desc}; setting up`); + } +} + +for (const method of ['GET', 'POST']) { + keepaliveSimpleRequestTest(method); +} diff --git a/test/wpt/tests/fetch/api/basic/mediasource.window.js b/test/wpt/tests/fetch/api/basic/mediasource.window.js new file mode 100644 index 0000000..1f89595 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/mediasource.window.js @@ -0,0 +1,5 @@ +promise_test(t => { + const mediaSource = new MediaSource(), + mediaSourceURL = URL.createObjectURL(mediaSource); + return promise_rejects_js(t, TypeError, fetch(mediaSourceURL)); +}, "Cannot fetch blob: URL from a MediaSource"); diff --git a/test/wpt/tests/fetch/api/basic/mode-no-cors.sub.any.js b/test/wpt/tests/fetch/api/basic/mode-no-cors.sub.any.js new file mode 100644 index 0000000..a4abcac --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/mode-no-cors.sub.any.js @@ -0,0 +1,29 @@ +// META: script=../resources/utils.js + +function fetchNoCors(url, isOpaqueFiltered) { + var urlQuery = "?pipe=header(x-is-filtered,value)" + promise_test(function(test) { + if (isOpaqueFiltered) + return fetch(url + urlQuery, {"mode": "no-cors"}).then(function(resp) { + assert_equals(resp.status, 0, "Opaque filter: status is 0"); + assert_equals(resp.statusText, "", "Opaque filter: statusText is \"\""); + assert_equals(resp.url, "", "Opaque filter: url is \"\""); + assert_equals(resp.type , "opaque", "Opaque filter: response's type is opaque"); + assert_equals(resp.headers.get("x-is-filtered"), null, "Header x-is-filtered is filtered"); + }); + else + return fetch(url + urlQuery, {"mode": "no-cors"}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-is-filtered"), "value", "Header x-is-filtered is not filtered"); + }); + }, "Fetch "+ url + " with no-cors mode"); +} + +fetchNoCors(RESOURCES_DIR + "top.txt", false); +fetchNoCors("http://{{host}}:{{ports[http][0]}}/fetch/api/resources/top.txt", false); +fetchNoCors("https://{{host}}:{{ports[https][0]}}/fetch/api/resources/top.txt", true); +fetchNoCors("http://{{host}}:{{ports[http][1]}}/fetch/api/resources/top.txt", true); + +done(); + diff --git a/test/wpt/tests/fetch/api/basic/mode-same-origin.any.js b/test/wpt/tests/fetch/api/basic/mode-same-origin.any.js new file mode 100644 index 0000000..1457702 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/mode-same-origin.any.js @@ -0,0 +1,28 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function fetchSameOrigin(url, shouldPass) { + promise_test(function(test) { + if (shouldPass) + return fetch(url , {"mode": "same-origin"}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + }); + else + return promise_rejects_js(test, TypeError, fetch(url, {mode: "same-origin"})); + }, "Fetch "+ url + " with same-origin mode"); +} + +var host_info = get_host_info(); + +fetchSameOrigin(RESOURCES_DIR + "top.txt", true); +fetchSameOrigin(host_info.HTTP_ORIGIN + "/fetch/api/resources/top.txt", true); +fetchSameOrigin(host_info.HTTPS_ORIGIN + "/fetch/api/resources/top.txt", false); +fetchSameOrigin(host_info.HTTP_REMOTE_ORIGIN + "/fetch/api/resources/top.txt", false); + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py?location="; + +fetchSameOrigin(redirPath + RESOURCES_DIR + "top.txt", true); +fetchSameOrigin(redirPath + host_info.HTTP_ORIGIN + "/fetch/api/resources/top.txt", true); +fetchSameOrigin(redirPath + host_info.HTTPS_ORIGIN + "/fetch/api/resources/top.txt", false); +fetchSameOrigin(redirPath + host_info.HTTP_REMOTE_ORIGIN + "/fetch/api/resources/top.txt", false); diff --git a/test/wpt/tests/fetch/api/basic/referrer.any.js b/test/wpt/tests/fetch/api/basic/referrer.any.js new file mode 100644 index 0000000..85745e6 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/referrer.any.js @@ -0,0 +1,29 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function runTest(url, init, expectedReferrer, title) { + promise_test(function(test) { + url += (url.indexOf('?') !== -1 ? '&' : '?') + "headers=referer&cors"; + + return fetch(url , init).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.headers.get("x-request-referer"), expectedReferrer, "Request's referrer is correct"); + }); + }, title); +} + +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py"; +var corsFetchedUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py"; +var redirectUrl = RESOURCES_DIR + "redirect.py?location=" ; +var corsRedirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py?location="; + +runTest(fetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, location.toString(), "origin-when-cross-origin policy on a same-origin URL"); +runTest(corsFetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a cross-origin URL"); +runTest(redirectUrl + corsFetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a cross-origin URL after same-origin redirection"); +runTest(corsRedirectUrl + fetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a same-origin URL after cross-origin redirection"); + + +var referrerUrlWithCredentials = get_host_info().HTTP_ORIGIN.replace("http://", "http://username:password@"); +runTest(fetchedUrl, {referrer: referrerUrlWithCredentials}, get_host_info().HTTP_ORIGIN + "/", "Referrer with credentials should be stripped"); +var referrerUrlWithFragmentIdentifier = get_host_info().HTTP_ORIGIN + "#fragmentIdentifier"; +runTest(fetchedUrl, {referrer: referrerUrlWithFragmentIdentifier}, get_host_info().HTTP_ORIGIN + "/", "Referrer with fragment ID should be stripped"); diff --git a/test/wpt/tests/fetch/api/basic/request-forbidden-headers.any.js b/test/wpt/tests/fetch/api/basic/request-forbidden-headers.any.js new file mode 100644 index 0000000..511ce60 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-forbidden-headers.any.js @@ -0,0 +1,100 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function requestForbiddenHeaders(desc, forbiddenHeaders) { + var url = RESOURCES_DIR + "inspect-headers.py"; + var requestInit = {"headers": forbiddenHeaders} + var urlParameters = "?headers=" + Object.keys(forbiddenHeaders).join("|"); + + promise_test(function(test){ + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + for (var header in forbiddenHeaders) + assert_not_equals(resp.headers.get("x-request-" + header), forbiddenHeaders[header], header + " does not have the value we defined"); + }); + }, desc); +} + +function requestValidOverrideHeaders(desc, validHeaders) { + var url = RESOURCES_DIR + "inspect-headers.py"; + var requestInit = {"headers": validHeaders} + var urlParameters = "?headers=" + Object.keys(validHeaders).join("|"); + + promise_test(function(test){ + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + for (var header in validHeaders) + assert_equals(resp.headers.get("x-request-" + header), validHeaders[header], header + "is not skipped for non-forbidden methods"); + }); + }, desc); +} + +requestForbiddenHeaders("Accept-Charset is a forbidden request header", {"Accept-Charset": "utf-8"}); +requestForbiddenHeaders("Accept-Encoding is a forbidden request header", {"Accept-Encoding": ""}); + +requestForbiddenHeaders("Access-Control-Request-Headers is a forbidden request header", {"Access-Control-Request-Headers": ""}); +requestForbiddenHeaders("Access-Control-Request-Method is a forbidden request header", {"Access-Control-Request-Method": ""}); +requestForbiddenHeaders( + 'Access-Control-Request-Private-Network is a forbidden request header', + {'Access-Control-Request-Private-Network': ''}); +requestForbiddenHeaders("Connection is a forbidden request header", {"Connection": "close"}); +requestForbiddenHeaders("Content-Length is a forbidden request header", {"Content-Length": "42"}); +requestForbiddenHeaders("Cookie is a forbidden request header", {"Cookie": "cookie=none"}); +requestForbiddenHeaders("Cookie2 is a forbidden request header", {"Cookie2": "cookie2=none"}); +requestForbiddenHeaders("Date is a forbidden request header", {"Date": "Wed, 04 May 1988 22:22:22 GMT"}); +requestForbiddenHeaders("DNT is a forbidden request header", {"DNT": "4"}); +requestForbiddenHeaders("Expect is a forbidden request header", {"Expect": "100-continue"}); +requestForbiddenHeaders("Host is a forbidden request header", {"Host": "http://wrong-host.com"}); +requestForbiddenHeaders("Keep-Alive is a forbidden request header", {"Keep-Alive": "timeout=15"}); +requestForbiddenHeaders("Origin is a forbidden request header", {"Origin": "http://wrong-origin.com"}); +requestForbiddenHeaders("Referer is a forbidden request header", {"Referer": "http://wrong-referer.com"}); +requestForbiddenHeaders("TE is a forbidden request header", {"TE": "trailers"}); +requestForbiddenHeaders("Trailer is a forbidden request header", {"Trailer": "Accept"}); +requestForbiddenHeaders("Transfer-Encoding is a forbidden request header", {"Transfer-Encoding": "chunked"}); +requestForbiddenHeaders("Upgrade is a forbidden request header", {"Upgrade": "HTTP/2.0"}); +requestForbiddenHeaders("Via is a forbidden request header", {"Via": "1.1 nowhere.com"}); +requestForbiddenHeaders("Proxy- is a forbidden request header", {"Proxy-": "value"}); +requestForbiddenHeaders("Proxy-Test is a forbidden request header", {"Proxy-Test": "value"}); +requestForbiddenHeaders("Sec- is a forbidden request header", {"Sec-": "value"}); +requestForbiddenHeaders("Sec-Test is a forbidden request header", {"Sec-Test": "value"}); + +let forbiddenMethods = [ + "TRACE", + "TRACK", + "CONNECT", + "trace", + "track", + "connect", + "trace,", + "GET,track ", + " connect", +]; + +let overrideHeaders = [ + "x-http-method-override", + "x-http-method", + "x-method-override", + "X-HTTP-METHOD-OVERRIDE", + "X-HTTP-METHOD", + "X-METHOD-OVERRIDE", +]; + +for (forbiddenMethod of forbiddenMethods) { + for (overrideHeader of overrideHeaders) { + requestForbiddenHeaders(`header ${overrideHeader} is forbidden to use value ${forbiddenMethod}`, {[overrideHeader]: forbiddenMethod}); + } +} + +let permittedValues = [ + "GETTRACE", + "GET", + "\",TRACE\",", +]; + +for (permittedValue of permittedValues) { + for (overrideHeader of overrideHeaders) { + requestValidOverrideHeaders(`header ${overrideHeader} is allowed to use value ${permittedValue}`, {[overrideHeader]: permittedValue}); + } +} diff --git a/test/wpt/tests/fetch/api/basic/request-head.any.js b/test/wpt/tests/fetch/api/basic/request-head.any.js new file mode 100644 index 0000000..e0b6afa --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-head.any.js @@ -0,0 +1,6 @@ +// META: global=window,worker + +promise_test(function(test) { + var requestInit = {"method": "HEAD", "body": "test"}; + return promise_rejects_js(test, TypeError, fetch(".", requestInit)); +}, "Fetch with HEAD with body"); diff --git a/test/wpt/tests/fetch/api/basic/request-headers-case.any.js b/test/wpt/tests/fetch/api/basic/request-headers-case.any.js new file mode 100644 index 0000000..4c10e71 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-headers-case.any.js @@ -0,0 +1,13 @@ +// META: global=window,worker + +promise_test(() => { + return fetch("/xhr/resources/echo-headers.py", {headers: [["THIS-is-A-test", 1], ["THIS-IS-A-TEST", 2]] }).then(res => res.text()).then(body => { + assert_regexp_match(body, /THIS-is-A-test: 1, 2/) + }) +}, "Multiple headers with the same name, different case (THIS-is-A-test first)") + +promise_test(() => { + return fetch("/xhr/resources/echo-headers.py", {headers: [["THIS-IS-A-TEST", 1], ["THIS-is-A-test", 2]] }).then(res => res.text()).then(body => { + assert_regexp_match(body, /THIS-IS-A-TEST: 1, 2/) + }) +}, "Multiple headers with the same name, different case (THIS-IS-A-TEST first)") diff --git a/test/wpt/tests/fetch/api/basic/request-headers-nonascii.any.js b/test/wpt/tests/fetch/api/basic/request-headers-nonascii.any.js new file mode 100644 index 0000000..4a9a801 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-headers-nonascii.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker + +// This tests characters that are not +// https://infra.spec.whatwg.org/#ascii-code-point +// but are still +// https://infra.spec.whatwg.org/#byte-value +// in request header values. +// Such request header values are valid and thus sent to servers. +// Characters outside the #byte-value range are tested e.g. in +// fetch/api/headers/headers-errors.html. + +promise_test(() => { + return fetch( + "../resources/inspect-headers.py?headers=accept|x-test", + {headers: { + "Accept": "before-æøå-after", + "X-Test": "before-ß-after" + }}) + .then(res => { + assert_equals( + res.headers.get("x-request-accept"), + "before-æøå-after", + "Accept Header"); + assert_equals( + res.headers.get("x-request-x-test"), + "before-ß-after", + "X-Test Header"); + }); +}, "Non-ascii bytes in request headers"); diff --git a/test/wpt/tests/fetch/api/basic/request-headers.any.js b/test/wpt/tests/fetch/api/basic/request-headers.any.js new file mode 100644 index 0000000..ac54256 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-headers.any.js @@ -0,0 +1,82 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkContentType(contentType, body) +{ + if (self.FormData && body instanceof self.FormData) { + assert_true(contentType.startsWith("multipart/form-data; boundary="), "Request should have header content-type starting with multipart/form-data; boundary=, but got " + contentType); + return; + } + + var expectedContentType = "text/plain;charset=UTF-8"; + if(body === null || body instanceof ArrayBuffer || body.buffer instanceof ArrayBuffer) + expectedContentType = null; + else if (body instanceof Blob) + expectedContentType = body.type ? body.type : null; + else if (body instanceof URLSearchParams) + expectedContentType = "application/x-www-form-urlencoded;charset=UTF-8"; + + assert_equals(contentType , expectedContentType, "Request should have header content-type: " + expectedContentType); +} + +function requestHeaders(desc, url, method, body, expectedOrigin, expectedContentLength) { + var urlParameters = "?headers=origin|user-agent|accept-charset|content-length|content-type"; + var requestInit = {"method": method} + promise_test(function(test){ + if (typeof body === "function") + body = body(); + if (body) + requestInit["body"] = body; + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_true(resp.headers.has("x-request-user-agent"), "Request has header user-agent"); + assert_false(resp.headers.has("accept-charset"), "Request has header accept-charset"); + assert_equals(resp.headers.get("x-request-origin") , expectedOrigin, "Request should have header origin: " + expectedOrigin); + if (expectedContentLength !== undefined) + assert_equals(resp.headers.get("x-request-content-length") , expectedContentLength, "Request should have header content-length: " + expectedContentLength); + checkContentType(resp.headers.get("x-request-content-type"), body); + }); + }, desc); +} + +var url = RESOURCES_DIR + "inspect-headers.py" + +requestHeaders("Fetch with GET", url, "GET", null, null, null); +requestHeaders("Fetch with HEAD", url, "HEAD", null, null, null); +requestHeaders("Fetch with PUT without body", url, "POST", null, location.origin, "0"); +requestHeaders("Fetch with PUT with body", url, "PUT", "Request's body", location.origin, "14"); +requestHeaders("Fetch with POST without body", url, "POST", null, location.origin, "0"); +requestHeaders("Fetch with POST with text body", url, "POST", "Request's body", location.origin, "14"); +requestHeaders("Fetch with POST with FormData body", url, "POST", function() { return new FormData(); }, location.origin); +requestHeaders("Fetch with POST with URLSearchParams body", url, "POST", function() { return new URLSearchParams("name=value"); }, location.origin, "10"); +requestHeaders("Fetch with POST with Blob body", url, "POST", new Blob(["Test"]), location.origin, "4"); +requestHeaders("Fetch with POST with ArrayBuffer body", url, "POST", new ArrayBuffer(4), location.origin, "4"); +requestHeaders("Fetch with POST with Uint8Array body", url, "POST", new Uint8Array(4), location.origin, "4"); +requestHeaders("Fetch with POST with Int8Array body", url, "POST", new Int8Array(4), location.origin, "4"); +requestHeaders("Fetch with POST with Float32Array body", url, "POST", new Float32Array(1), location.origin, "4"); +requestHeaders("Fetch with POST with Float64Array body", url, "POST", new Float64Array(1), location.origin, "8"); +requestHeaders("Fetch with POST with DataView body", url, "POST", new DataView(new ArrayBuffer(8), 0, 4), location.origin, "4"); +requestHeaders("Fetch with POST with Blob body with mime type", url, "POST", new Blob(["Test"], { type: "text/maybe" }), location.origin, "4"); +requestHeaders("Fetch with Chicken", url, "Chicken", null, location.origin, null); +requestHeaders("Fetch with Chicken with body", url, "Chicken", "Request's body", location.origin, "14"); + +function requestOriginHeader(method, mode, needsOrigin) { + promise_test(function(test){ + return fetch(url + "?headers=origin", {method:method, mode:mode}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + if(needsOrigin) + assert_equals(resp.headers.get("x-request-origin") , location.origin, "Request should have an Origin header with origin: " + location.origin); + else + assert_equals(resp.headers.get("x-request-origin"), null, "Request should not have an Origin header") + }); + }, "Fetch with " + method + " and mode \"" + mode + "\" " + (needsOrigin ? "needs" : "does not need") + " an Origin header"); +} + +requestOriginHeader("GET", "cors", false); +requestOriginHeader("POST", "same-origin", true); +requestOriginHeader("POST", "no-cors", true); +requestOriginHeader("PUT", "same-origin", true); +requestOriginHeader("TacO", "same-origin", true); +requestOriginHeader("TacO", "cors", true); diff --git a/test/wpt/tests/fetch/api/basic/request-referrer-redirected-worker.html b/test/wpt/tests/fetch/api/basic/request-referrer-redirected-worker.html new file mode 100644 index 0000000..bdea1e1 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-referrer-redirected-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer header + + + + + + + diff --git a/test/wpt/tests/fetch/api/basic/request-referrer.any.js b/test/wpt/tests/fetch/api/basic/request-referrer.any.js new file mode 100644 index 0000000..0c33576 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-referrer.any.js @@ -0,0 +1,24 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function testReferrer(referrer, expected, desc) { + promise_test(function(test) { + var url = RESOURCES_DIR + "inspect-headers.py?headers=referer" + var req = new Request(url, { referrer: referrer }); + return fetch(req).then(function(resp) { + var actual = resp.headers.get("x-request-referer"); + if (expected) { + assert_equals(actual, expected, "request's referer should be: " + expected); + return; + } + if (actual) { + assert_equals(actual, "", "request's referer should be empty"); + } + }); + }, desc); +} + +testReferrer("about:client", self.location.href, 'about:client referrer'); + +var fooURL = new URL("./foo", self.location).href; +testReferrer(fooURL, fooURL, 'url referrer'); diff --git a/test/wpt/tests/fetch/api/basic/request-upload.any.js b/test/wpt/tests/fetch/api/basic/request-upload.any.js new file mode 100644 index 0000000..9168aa1 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-upload.any.js @@ -0,0 +1,135 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +function testUpload(desc, url, method, createBody, expectedBody) { + const requestInit = {method}; + promise_test(function(test){ + const body = createBody(); + if (body) { + requestInit["body"] = body; + requestInit.duplex = "half"; + } + return fetch(url, requestInit).then(function(resp) { + return resp.text().then((text)=> { + assert_equals(text, expectedBody); + }); + }); + }, desc); +} + +function testUploadFailure(desc, url, method, createBody) { + const requestInit = {method}; + promise_test(t => { + const body = createBody(); + if (body) { + requestInit["body"] = body; + } + return promise_rejects_js(t, TypeError, fetch(url, requestInit)); + }, desc); +} + +const url = RESOURCES_DIR + "echo-content.py" + +testUpload("Fetch with PUT with body", url, + "PUT", + () => "Request's body", + "Request's body"); +testUpload("Fetch with POST with text body", url, + "POST", + () => "Request's body", + "Request's body"); +testUpload("Fetch with POST with URLSearchParams body", url, + "POST", + () => new URLSearchParams("name=value"), + "name=value"); +testUpload("Fetch with POST with Blob body", url, + "POST", + () => new Blob(["Test"]), + "Test"); +testUpload("Fetch with POST with ArrayBuffer body", url, + "POST", + () => new ArrayBuffer(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Uint8Array body", url, + "POST", + () => new Uint8Array(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Int8Array body", url, + "POST", + () => new Int8Array(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Float32Array body", url, + "POST", + () => new Float32Array(1), + "\0\0\0\0"); +testUpload("Fetch with POST with Float64Array body", url, + "POST", + () => new Float64Array(1), + "\0\0\0\0\0\0\0\0"); +testUpload("Fetch with POST with DataView body", url, + "POST", + () => new DataView(new ArrayBuffer(8), 0, 4), + "\0\0\0\0"); +testUpload("Fetch with POST with Blob body with mime type", url, + "POST", + () => new Blob(["Test"], { type: "text/maybe" }), + "Test"); + +testUploadFailure("Fetch with POST with ReadableStream containing String", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue("Test"); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing null", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(null); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing number", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(99); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing ArrayBuffer", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(new ArrayBuffer()); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing Blob", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(new Blob()); + controller.close(); + }}) + }); + +promise_test(async (test) => { + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=421&uuid=${token()}&partition_id=${get_host_info().ORIGIN}` + + `&dispatch=check_partition&addcounter=true`, + {method: "POST", body: "foobar"}); + assert_equals(resp.status, 421); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 2 times. 2 connections were created."); +}, "Fetch with POST with text body on 421 response should be retried once on new connection."); + +promise_test(async (test) => { + const body = new ReadableStream({start: c => c.close()}); + await promise_rejects_js(test, TypeError, fetch('/', {method: 'POST', body})); +}, "Streaming upload shouldn't work on Http/1.1."); diff --git a/test/wpt/tests/fetch/api/basic/request-upload.h2.any.js b/test/wpt/tests/fetch/api/basic/request-upload.h2.any.js new file mode 100644 index 0000000..eedc2bf --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/request-upload.h2.any.js @@ -0,0 +1,186 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +const duplex = "half"; + +async function assertUpload(url, method, createBody, expectedBody) { + const requestInit = {method}; + const body = createBody(); + if (body) { + requestInit["body"] = body; + requestInit.duplex = "half"; + } + const resp = await fetch(url, requestInit); + const text = await resp.text(); + assert_equals(text, expectedBody); +} + +function testUpload(desc, url, method, createBody, expectedBody) { + promise_test(async () => { + await assertUpload(url, method, createBody, expectedBody); + }, desc); +} + +function createStream(chunks) { + return new ReadableStream({ + start: (controller) => { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + } + }); +} + +const url = RESOURCES_DIR + "echo-content.h2.py" + +testUpload("Fetch with POST with empty ReadableStream", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.close(); + }}) + }, + ""); + +testUpload("Fetch with POST with ReadableStream", url, + "POST", + () => { + return new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}) + }, + "Test"); + +promise_test(async (test) => { + const body = new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}); + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=421&uuid=${token()}&partition_id=${self.origin}` + + `&dispatch=check_partition&addcounter=true`, + {method: "POST", body: body, duplex}); + assert_equals(resp.status, 421); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 1 times. 1 connections were created."); +}, "Fetch with POST with ReadableStream on 421 response should return the response and not retry."); + +promise_test(async (test) => { + const request = new Request('', { + body: new ReadableStream(), + method: 'POST', + duplex, + }); + + assert_equals(request.headers.get('Content-Type'), null, `Request should not have a content-type set`); + + const response = await fetch('data:a/a;charset=utf-8,test', { + method: 'POST', + body: new ReadableStream(), + duplex, + }); + + assert_equals(await response.text(), 'test', `Response has correct body`); +}, "Feature detect for POST with ReadableStream"); + +promise_test(async (test) => { + const request = new Request('data:a/a;charset=utf-8,test', { + body: new ReadableStream(), + method: 'POST', + duplex, + }); + + assert_equals(request.headers.get('Content-Type'), null, `Request should not have a content-type set`); + const response = await fetch(request); + assert_equals(await response.text(), 'test', `Response has correct body`); +}, "Feature detect for POST with ReadableStream, using request object"); + +test(() => { + let duplexAccessed = false; + + const request = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + }); + + assert_equals( + request.headers.get("Content-Type"), + null, + `Request should not have a content-type set` + ); + assert_true(duplexAccessed, `duplex dictionary property should be accessed`); +}, "Synchronous feature detect"); + +// The asserts the synchronousFeatureDetect isn't broken by a partial implementation. +// An earlier feature detect was broken by Safari implementing streaming bodies as part of Request, +// but it failed when passed to fetch(). +// This tests ensures that UAs must not implement RequestInit.duplex and streaming request bodies without also implementing the fetch() parts. +promise_test(async () => { + let duplexAccessed = false; + + const request = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + }); + + const supported = + request.headers.get("Content-Type") === null && duplexAccessed; + + // If the feature detect fails, assume the browser is being truthful (other tests pick up broken cases here) + if (!supported) return false; + + await assertUpload( + url, + "POST", + () => + new ReadableStream({ + start: (controller) => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }, + }), + "Test" + ); +}, "Synchronous feature detect fails if feature unsupported"); + +promise_test(async (t) => { + const body = createStream(["hello"]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing a String"); + +promise_test(async (t) => { + const body = createStream([null]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing null"); + +promise_test(async (t) => { + const body = createStream([33]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing a number"); + +promise_test(async (t) => { + const url = "/fetch/api/resources/authentication.py?realm=test"; + const body = createStream([]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload should fail on a 401 response"); + diff --git a/test/wpt/tests/fetch/api/basic/response-null-body.any.js b/test/wpt/tests/fetch/api/basic/response-null-body.any.js new file mode 100644 index 0000000..bb05892 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/response-null-body.any.js @@ -0,0 +1,38 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +const nullBodyStatus = [204, 205, 304]; +const methods = ["GET", "POST", "OPTIONS"]; + +for (const status of nullBodyStatus) { + for (const method of methods) { + promise_test( + async () => { + const url = + `${RESOURCES_DIR}status.py?code=${status}&content=hello-world`; + const resp = await fetch(url, { method }); + assert_equals(resp.status, status); + assert_equals(resp.body, null, "the body should be null"); + const text = await resp.text(); + assert_equals(text, "", "null bodies result in empty text"); + }, + `Response.body is null for responses with status=${status} (method=${method})`, + ); + } +} + +promise_test(async () => { + const url = `${RESOURCES_DIR}status.py?code=200&content=hello-world`; + const resp = await fetch(url, { method: "HEAD" }); + assert_equals(resp.status, 200); + assert_equals(resp.body, null, "the body should be null"); + const text = await resp.text(); + assert_equals(text, "", "null bodies result in empty text"); +}, `Response.body is null for responses with method=HEAD`); + +promise_test(async (t) => { + const integrity = "sha384-UT6f7WCFp32YJnp1is4l/ZYnOeQKpE8xjmdkLOwZ3nIP+tmT2aMRFQGJomjVf5cE"; + const url = `${RESOURCES_DIR}status.py?code=204&content=hello-world`; + const promise = fetch(url, { method: "GET", integrity }); + promise_rejects_js(t, TypeError, promise); +}, "Null body status with subresource integrity should abort"); diff --git a/test/wpt/tests/fetch/api/basic/response-url.sub.any.js b/test/wpt/tests/fetch/api/basic/response-url.sub.any.js new file mode 100644 index 0000000..0d123c4 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/response-url.sub.any.js @@ -0,0 +1,16 @@ +function checkResponseURL(fetchedURL, expectedURL) +{ + promise_test(function() { + return fetch(fetchedURL).then(function(response) { + assert_equals(response.url, expectedURL); + }); + }, "Testing response url getter with " +fetchedURL); +} + +var baseURL = "http://{{host}}:{{ports[http][0]}}"; +checkResponseURL(baseURL + "/ada", baseURL + "/ada"); +checkResponseURL(baseURL + "/#", baseURL + "/"); +checkResponseURL(baseURL + "/#ada", baseURL + "/"); +checkResponseURL(baseURL + "#ada", baseURL + "/"); + +done(); diff --git a/test/wpt/tests/fetch/api/basic/scheme-about.any.js b/test/wpt/tests/fetch/api/basic/scheme-about.any.js new file mode 100644 index 0000000..9ef4418 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/scheme-about.any.js @@ -0,0 +1,26 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkNetworkError(url, method) { + method = method || "GET"; + const desc = "Fetching " + url.substring(0, 45) + " with method " + method + " is KO" + promise_test(function(test) { + var promise = fetch(url, { method: method }); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +checkNetworkError("about:blank", "GET"); +checkNetworkError("about:blank", "PUT"); +checkNetworkError("about:blank", "POST"); +checkNetworkError("about:invalid.com"); +checkNetworkError("about:config"); +checkNetworkError("about:unicorn"); + +promise_test(function(test) { + var promise = fetch("about:blank", { + "method": "GET", + "Range": "bytes=1-10" + }); + return promise_rejects_js(test, TypeError, promise); +}, "Fetching about:blank with range header does not affect behavior"); diff --git a/test/wpt/tests/fetch/api/basic/scheme-blob.sub.any.js b/test/wpt/tests/fetch/api/basic/scheme-blob.sub.any.js new file mode 100644 index 0000000..8afdc03 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/scheme-blob.sub.any.js @@ -0,0 +1,125 @@ +// META: script=../resources/utils.js + +function checkFetchResponse(url, data, mime, size, desc) { + promise_test(function(test) { + size = size.toString(); + return fetch(url).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), mime, "Content-Type is " + resp.headers.get("Content-Type")); + assert_equals(resp.headers.get("Content-Length"), size, "Content-Length is " + resp.headers.get("Content-Length")); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, data, "Response's body is " + data); + }); + }, desc); +} + +var blob = new Blob(["Blob's data"], { "type" : "text/plain" }); +checkFetchResponse(URL.createObjectURL(blob), "Blob's data", "text/plain", blob.size, + "Fetching [GET] URL.createObjectURL(blob) is OK"); + +function checkKoUrl(url, method, desc) { + promise_test(function(test) { + var promise = fetch(url, {"method": method}); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +var blob2 = new Blob(["Blob's data"], { "type" : "text/plain" }); +checkKoUrl("blob:http://{{domains[www]}}:{{ports[http][0]}}/", "GET", + "Fetching [GET] blob:http://{{domains[www]}}:{{ports[http][0]}}/ is KO"); + +var invalidRequestMethods = [ + "POST", + "OPTIONS", + "HEAD", + "PUT", + "DELETE", + "INVALID", +]; +invalidRequestMethods.forEach(function(method) { + checkKoUrl(URL.createObjectURL(blob2), method, "Fetching [" + method + "] URL.createObjectURL(blob) is KO"); +}); + +checkKoUrl("blob:not-backed-by-a-blob/", "GET", + "Fetching [GET] blob:not-backed-by-a-blob/ is KO"); + +let empty_blob = new Blob([]); +checkFetchResponse(URL.createObjectURL(empty_blob), "", "", 0, + "Fetching URL.createObjectURL(empty_blob) is OK"); + +let empty_type_blob = new Blob([], {type: ""}); +checkFetchResponse(URL.createObjectURL(empty_type_blob), "", "", 0, + "Fetching URL.createObjectURL(empty_type_blob) is OK"); + +let empty_data_blob = new Blob([], {type: "text/plain"}); +checkFetchResponse(URL.createObjectURL(empty_data_blob), "", "text/plain", 0, + "Fetching URL.createObjectURL(empty_data_blob) is OK"); + +let invalid_type_blob = new Blob([], {type: "invalid"}); +checkFetchResponse(URL.createObjectURL(invalid_type_blob), "", "", 0, + "Fetching URL.createObjectURL(invalid_type_blob) is OK"); + +promise_test(function(test) { + return fetch("/images/blue.png").then(function(resp) { + return resp.arrayBuffer(); + }).then(function(image_buffer) { + let blob = new Blob([image_buffer]); + return fetch(URL.createObjectURL(blob)).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), "", "Content-Type is " + resp.headers.get("Content-Type")); + }) + }); +}, "Blob content is not sniffed for a content type [image/png]"); + +let simple_xml_string = ''; +let xml_blob_no_type = new Blob([simple_xml_string]); +checkFetchResponse(URL.createObjectURL(xml_blob_no_type), simple_xml_string, "", 45, + "Blob content is not sniffed for a content type [text/xml]"); + +let simple_text_string = 'Hello, World!'; +promise_test(function(test) { + let blob = new Blob([simple_text_string], {"type": "text/plain"}); + let slice = blob.slice(7, simple_text_string.length, "\0"); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "6"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, "World!"); + }); +}, "Set content type to the empty string for slice with invalid content type"); + +promise_test(function(test) { + let blob = new Blob([simple_text_string], {"type": "text/plain"}); + let slice = blob.slice(7, simple_text_string.length, "\0"); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "6"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, "World!"); + }); +}, "Set content type to the empty string for slice with no content type "); + +promise_test(function(test) { + let blob = new Blob([simple_xml_string]); + let slice = blob.slice(0, 38); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "38"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, ''); + }); +}, "Blob.slice should not sniff the content for a content type"); + +done(); diff --git a/test/wpt/tests/fetch/api/basic/scheme-data.any.js b/test/wpt/tests/fetch/api/basic/scheme-data.any.js new file mode 100644 index 0000000..55df43b --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/scheme-data.any.js @@ -0,0 +1,43 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkFetchResponse(url, data, mime, fetchMode, method) { + var cut = (url.length >= 40) ? "[...]" : ""; + var desc = "Fetching " + (method ? "[" + method + "] " : "") + url.substring(0, 40) + cut + " is OK"; + var init = {"method": method || "GET"}; + if (fetchMode) { + init.mode = fetchMode; + desc += " (" + fetchMode + ")"; + } + promise_test(function(test) { + return fetch(url, init).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.statusText, "OK", "HTTP statusText is OK"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), mime, "Content-Type is " + resp.headers.get("Content-Type")); + return resp.text(); + }).then(function(body) { + assert_equals(body, data, "Response's body is correct"); + }); + }, desc); +} + +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", "same-origin"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", "cors"); +checkFetchResponse("data:text/plain;base64,cmVzcG9uc2UncyBib2R5", "response's body", "text/plain"); +checkFetchResponse("data:image/png;base64,cmVzcG9uc2UncyBib2R5", + "response's body", + "image/png"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", null, "POST"); +checkFetchResponse("data:,response%27s%20body", "", "text/plain;charset=US-ASCII", null, "HEAD"); + +function checkKoUrl(url, method, desc) { + var cut = (url.length >= 40) ? "[...]" : ""; + desc = "Fetching [" + method + "] " + url.substring(0, 45) + cut + " is KO" + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, {"method": method})); + }, desc); +} + +checkKoUrl("data:notAdataUrl.com", "GET"); diff --git a/test/wpt/tests/fetch/api/basic/scheme-others.sub.any.js b/test/wpt/tests/fetch/api/basic/scheme-others.sub.any.js new file mode 100644 index 0000000..550f69c --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/scheme-others.sub.any.js @@ -0,0 +1,31 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkKoUrl(url, desc) { + if (!desc) + desc = "Fetching " + url.substring(0, 45) + " is KO" + promise_test(function(test) { + var promise = fetch(url); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +var urlWithoutScheme = "://{{host}}:{{ports[http][0]}}/"; +checkKoUrl("aaa" + urlWithoutScheme); +checkKoUrl("cap" + urlWithoutScheme); +checkKoUrl("cid" + urlWithoutScheme); +checkKoUrl("dav" + urlWithoutScheme); +checkKoUrl("dict" + urlWithoutScheme); +checkKoUrl("dns" + urlWithoutScheme); +checkKoUrl("geo" + urlWithoutScheme); +checkKoUrl("im" + urlWithoutScheme); +checkKoUrl("imap" + urlWithoutScheme); +checkKoUrl("ipp" + urlWithoutScheme); +checkKoUrl("ldap" + urlWithoutScheme); +checkKoUrl("mailto" + urlWithoutScheme); +checkKoUrl("nfs" + urlWithoutScheme); +checkKoUrl("pop" + urlWithoutScheme); +checkKoUrl("rtsp" + urlWithoutScheme); +checkKoUrl("snmp" + urlWithoutScheme); + +done(); diff --git a/test/wpt/tests/fetch/api/basic/status.h2.any.js b/test/wpt/tests/fetch/api/basic/status.h2.any.js new file mode 100644 index 0000000..99fec88 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/status.h2.any.js @@ -0,0 +1,17 @@ +// See also /xhr/status.h2.window.js + +[ + 200, + 210, + 400, + 404, + 410, + 500, + 502 +].forEach(status => { + promise_test(async t => { + const response = await fetch("/xhr/resources/status.py?code=" + status); + assert_equals(response.status, status, "status should be " + status); + assert_equals(response.statusText, "", "statusText should be the empty string"); + }, "statusText over H2 for status " + status + " should be the empty string"); +}); diff --git a/test/wpt/tests/fetch/api/basic/stream-response.any.js b/test/wpt/tests/fetch/api/basic/stream-response.any.js new file mode 100644 index 0000000..d964dda --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/stream-response.any.js @@ -0,0 +1,40 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function streamBody(reader, test, count = 0) { + return reader.read().then(function(data) { + if (!data.done && count < 2) { + count += 1; + return streamBody(reader, test, count); + } else { + test.step(function() { + assert_true(count >= 2, "Retrieve body progressively"); + }); + } + }); +} + +//simulate streaming: +//count is large enough to let the UA deliver the body before it is completely retrieved +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(resp) { + if (resp.body) + return streamBody(resp.body.getReader(), test); + else + test.step(function() { + assert_unreached( "Body does not exist in response"); + }); + }); +}, "Stream response's body when content-type is present"); + +// This test makes sure that the response body is not buffered if no content type is provided. +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=300&count=10¬ype=true").then(function(resp) { + if (resp.body) + return streamBody(resp.body.getReader(), test); + else + test.step(function() { + assert_unreached( "Body does not exist in response"); + }); + }); +}, "Stream response's body when content-type is not present"); diff --git a/test/wpt/tests/fetch/api/basic/stream-safe-creation.any.js b/test/wpt/tests/fetch/api/basic/stream-safe-creation.any.js new file mode 100644 index 0000000..382efc1 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/stream-safe-creation.any.js @@ -0,0 +1,54 @@ +// META: global=window,worker + +// These tests verify that stream creation is not affected by changes to +// Object.prototype. + +const creationCases = { + fetch: async () => fetch(location.href), + request: () => new Request(location.href, {method: 'POST', body: 'hi'}), + response: () => new Response('bye'), + consumeEmptyResponse: () => new Response().text(), + consumeNonEmptyResponse: () => new Response(new Uint8Array([64])).text(), + consumeEmptyRequest: () => new Request(location.href).text(), + consumeNonEmptyRequest: () => new Request(location.href, + {method: 'POST', body: 'yes'}).arrayBuffer(), +}; + +for (const creationCase of Object.keys(creationCases)) { + for (const accessorName of ['start', 'type', 'size', 'highWaterMark']) { + promise_test(async t => { + Object.defineProperty(Object.prototype, accessorName, { + get() { throw Error(`Object.prototype.${accessorName} was accessed`); }, + configurable: true + }); + t.add_cleanup(() => { + delete Object.prototype[accessorName]; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `throwing Object.prototype.${accessorName} accessor should not affect ` + + `stream creation by '${creationCase}'`); + + promise_test(async t => { + // -1 is a convenient value which is invalid, and should cause the + // constructor to throw, for all four fields. + Object.prototype[accessorName] = -1; + t.add_cleanup(() => { + delete Object.prototype[accessorName]; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `Object.prototype.${accessorName} accessor returning invalid value ` + + `should not affect stream creation by '${creationCase}'`); + } + + promise_test(async t => { + Object.prototype.start = controller => controller.error(new Error('start')); + t.add_cleanup(() => { + delete Object.prototype.start; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `Object.prototype.start function which errors the stream should not ` + + `affect stream creation by '${creationCase}'`); +} diff --git a/test/wpt/tests/fetch/api/basic/text-utf8.any.js b/test/wpt/tests/fetch/api/basic/text-utf8.any.js new file mode 100644 index 0000000..05c8c88 --- /dev/null +++ b/test/wpt/tests/fetch/api/basic/text-utf8.any.js @@ -0,0 +1,74 @@ +// META: title=Fetch: Request and Response text() should decode as UTF-8 +// META: global=window,worker +// META: script=../resources/utils.js + +function testTextDecoding(body, expectedText, urlParameter, title) +{ + var arrayBuffer = stringToArray(body); + + promise_test(function(test) { + var request = new Request("", {method: "POST", body: arrayBuffer}); + return request.text().then(function(value) { + assert_equals(value, expectedText, "Request.text() should decode data as UTF-8"); + }); + }, title + " with Request.text()"); + + promise_test(function(test) { + var response = new Response(arrayBuffer); + return response.text().then(function(value) { + assert_equals(value, expectedText, "Response.text() should decode data as UTF-8"); + }); + }, title + " with Response.text()"); + + promise_test(function(test) { + return fetch("../resources/status.py?code=200&type=text%2Fplain%3Bcharset%3DUTF-8&content=" + urlParameter).then(function(response) { + return response.text().then(function(value) { + assert_equals(value, expectedText, "Fetched Response.text() should decode data as UTF-8"); + }); + }); + }, title + " with fetched data (UTF-8 charset)"); + + promise_test(function(test) { + return fetch("../resources/status.py?code=200&type=text%2Fplain%3Bcharset%3DUTF-16&content=" + urlParameter).then(function(response) { + return response.text().then(function(value) { + assert_equals(value, expectedText, "Fetched Response.text() should decode data as UTF-8"); + }); + }); + }, title + " with fetched data (UTF-16 charset)"); + + promise_test(function(test) { + return new Response(body).arrayBuffer().then(function(buffer) { + assert_array_equals(new Uint8Array(buffer), encode_utf8(body), "Response.arrayBuffer() should contain data encoded as UTF-8"); + }); + }, title + " (Response object)"); + + promise_test(function(test) { + return new Request("", {method: "POST", body: body}).arrayBuffer().then(function(buffer) { + assert_array_equals(new Uint8Array(buffer), encode_utf8(body), "Request.arrayBuffer() should contain data encoded as UTF-8"); + }); + }, title + " (Request object)"); + +} + +var utf8WithBOM = "\xef\xbb\xbf\xe4\xb8\x89\xe6\x9d\x91\xe3\x81\x8b\xe3\x81\xaa\xe5\xad\x90"; +var utf8WithBOMAsURLParameter = "%EF%BB%BF%E4%B8%89%E6%9D%91%E3%81%8B%E3%81%AA%E5%AD%90"; +var utf8WithoutBOM = "\xe4\xb8\x89\xe6\x9d\x91\xe3\x81\x8b\xe3\x81\xaa\xe5\xad\x90"; +var utf8WithoutBOMAsURLParameter = "%E4%B8%89%E6%9D%91%E3%81%8B%E3%81%AA%E5%AD%90"; +var utf8Decoded = "三村かな子"; +testTextDecoding(utf8WithBOM, utf8Decoded, utf8WithBOMAsURLParameter, "UTF-8 with BOM"); +testTextDecoding(utf8WithoutBOM, utf8Decoded, utf8WithoutBOMAsURLParameter, "UTF-8 without BOM"); + +var utf16BEWithBOM = "\xfe\xff\x4e\x09\x67\x51\x30\x4b\x30\x6a\x5b\x50"; +var utf16BEWithBOMAsURLParameter = "%fe%ff%4e%09%67%51%30%4b%30%6a%5b%50"; +var utf16BEWithBOMDecodedAsUTF8 = "��N\tgQ0K0j[P"; +testTextDecoding(utf16BEWithBOM, utf16BEWithBOMDecodedAsUTF8, utf16BEWithBOMAsURLParameter, "UTF-16BE with BOM decoded as UTF-8"); + +var utf16LEWithBOM = "\xff\xfe\x09\x4e\x51\x67\x4b\x30\x6a\x30\x50\x5b"; +var utf16LEWithBOMAsURLParameter = "%ff%fe%09%4e%51%67%4b%30%6a%30%50%5b"; +var utf16LEWithBOMDecodedAsUTF8 = "��\tNQgK0j0P["; +testTextDecoding(utf16LEWithBOM, utf16LEWithBOMDecodedAsUTF8, utf16LEWithBOMAsURLParameter, "UTF-16LE with BOM decoded as UTF-8"); + +var utf16WithoutBOM = "\xe6\x00\xf8\x00\xe5\x00\x0a\x00\xc6\x30\xb9\x30\xc8\x30\x0a\x00"; +var utf16WithoutBOMAsURLParameter = "%E6%00%F8%00%E5%00%0A%00%C6%30%B9%30%C8%30%0A%00"; +var utf16WithoutBOMDecoded = "\ufffd\u0000\ufffd\u0000\ufffd\u0000\u000a\u0000\ufffd\u0030\ufffd\u0030\ufffd\u0030\u000a\u0000"; +testTextDecoding(utf16WithoutBOM, utf16WithoutBOMDecoded, utf16WithoutBOMAsURLParameter, "UTF-16 without BOM decoded as UTF-8"); diff --git a/test/wpt/tests/fetch/api/body/cloned-any.js b/test/wpt/tests/fetch/api/body/cloned-any.js new file mode 100644 index 0000000..2bca96c --- /dev/null +++ b/test/wpt/tests/fetch/api/body/cloned-any.js @@ -0,0 +1,50 @@ +// Changing the body after it have been passed to Response/Request +// should not change the outcome of the consumed body + +const url = 'http://a'; +const method = 'post'; + +promise_test(async t => { + const body = new FormData(); + body.set('a', '1'); + const res = new Response(body); + const req = new Request(url, { method, body }); + body.set('a', '2'); + assert_true((await res.formData()).get('a') === '1'); + assert_true((await req.formData()).get('a') === '1'); +}, 'FormData is cloned'); + +promise_test(async t => { + const body = new URLSearchParams({a: '1'}); + const res = new Response(body); + const req = new Request(url, { method, body }); + body.set('a', '2'); + assert_true((await res.formData()).get('a') === '1'); + assert_true((await req.formData()).get('a') === '1'); +}, 'URLSearchParams is cloned'); + +promise_test(async t => { + const body = new Uint8Array([97]); // a + const res = new Response(body); + const req = new Request(url, { method, body }); + body[0] = 98; // b + assert_true(await res.text() === 'a'); + assert_true(await req.text() === 'a'); +}, 'TypedArray is cloned'); + +promise_test(async t => { + const body = new Uint8Array([97]); // a + const res = new Response(body.buffer); + const req = new Request(url, { method, body: body.buffer }); + body[0] = 98; // b + assert_true(await res.text() === 'a'); + assert_true(await req.text() === 'a'); +}, 'ArrayBuffer is cloned'); + +promise_test(async t => { + const body = new Blob(['a']); + const res = new Response(body); + const req = new Request(url, { method, body }); + assert_true(await res.blob() !== body); + assert_true(await req.blob() !== body); +}, 'Blob is cloned'); diff --git a/test/wpt/tests/fetch/api/body/formdata.any.js b/test/wpt/tests/fetch/api/body/formdata.any.js new file mode 100644 index 0000000..e250359 --- /dev/null +++ b/test/wpt/tests/fetch/api/body/formdata.any.js @@ -0,0 +1,14 @@ +promise_test(async t => { + const res = new Response(new FormData()); + const fd = await res.formData(); + assert_true(fd instanceof FormData); +}, 'Consume empty response.formData() as FormData'); + +promise_test(async t => { + const req = new Request('about:blank', { + method: 'POST', + body: new FormData() + }); + const fd = await req.formData(); + assert_true(fd instanceof FormData); +}, 'Consume empty request.formData() as FormData'); diff --git a/test/wpt/tests/fetch/api/body/mime-type.any.js b/test/wpt/tests/fetch/api/body/mime-type.any.js new file mode 100644 index 0000000..67c9af7 --- /dev/null +++ b/test/wpt/tests/fetch/api/body/mime-type.any.js @@ -0,0 +1,127 @@ +[ + () => new Request("about:blank", { headers: { "Content-Type": "text/plain" } }), + () => new Response("", { headers: { "Content-Type": "text/plain" } }) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + const newMIMEType = "test/test"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: overriding explicit Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new URLSearchParams(), method: "POST" }), + () => new Response(new URLSearchParams()), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "application/x-www-form-urlencoded;charset=UTF-8"); + bodyContainer.headers.delete("Content-Type"); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + }, `${bodyContainer.constructor.name}: removing implicit Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new ArrayBuffer(), method: "POST" }), + () => new Response(new ArrayBuffer()), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), null); + const newMIMEType = "test/test"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: setting missing Content-Type`); +}); + +[ + () => new Request("about:blank", { method: "POST" }), + () => new Response(), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + }, `${bodyContainer.constructor.name}: MIME type for Blob from empty body`); +}); + +[ + () => new Request("about:blank", { method: "POST", headers: [["Content-Type", "Mytext/Plain"]] }), + () => new Response("", { headers: [["Content-Type", "Mytext/Plain"]] }) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, 'mytext/plain'); + }, `${bodyContainer.constructor.name}: MIME type for Blob from empty body with Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new Blob([""]), method: "POST" }), + () => new Response(new Blob([""])) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + assert_equals(bodyContainer.headers.get("Content-Type"), null); + }, `${bodyContainer.constructor.name}: MIME type for Blob`); +}); + +[ + () => new Request("about:blank", { body: new Blob([""], { type: "Text/Plain" }), method: "POST" }), + () => new Response(new Blob([""], { type: "Text/Plain" })) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, "text/plain"); + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + }, `${bodyContainer.constructor.name}: MIME type for Blob with non-empty type`); +}); + +[ + () => new Request("about:blank", { method: "POST", body: new Blob([""], { type: "Text/Plain" }), headers: [["Content-Type", "Text/Html"]] }), + () => new Response(new Blob([""], { type: "Text/Plain" }, { headers: [["Content-Type", "Text/Html"]] })) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + const cloned = bodyContainer.clone(); + promise_test(async t => { + const blobs = [await bodyContainer.blob(), await cloned.blob()]; + assert_equals(blobs[0].type, "text/html"); + assert_equals(blobs[1].type, "text/html"); + assert_equals(bodyContainer.headers.get("Content-Type"), "Text/Html"); + assert_equals(cloned.headers.get("Content-Type"), "Text/Html"); + }, `${bodyContainer.constructor.name}: Extract a MIME type with clone`); +}); + +[ + () => new Request("about:blank", { body: new Blob([], { type: "text/plain" }), method: "POST", headers: [["Content-Type", "text/html"]] }), + () => new Response(new Blob([], { type: "text/plain" }), { headers: [["Content-Type", "text/html"]] }), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/html"); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, "text/html"); + }, `${bodyContainer.constructor.name}: Content-Type in headers wins Blob"s type`); +}); + +[ + () => new Request("about:blank", { body: new Blob([], { type: "text/plain" }), method: "POST" }), + () => new Response(new Blob([], { type: "text/plain" })), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + const newMIMEType = "text/html"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: setting missing Content-Type in headers and it wins Blob"s type`); +}); diff --git a/test/wpt/tests/fetch/api/cors/cors-basic.any.js b/test/wpt/tests/fetch/api/cors/cors-basic.any.js new file mode 100644 index 0000000..95de0af --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-basic.any.js @@ -0,0 +1,43 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const { + HTTPS_ORIGIN, + HTTP_ORIGIN_WITH_DIFFERENT_PORT, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + HTTPS_REMOTE_ORIGIN, +} = get_host_info(); + +function cors(desc, origin) { + const url = `${origin}${dirname(location.pathname)}${RESOURCES_DIR}top.txt`; + const urlAllowCors = `${url}?pipe=header(Access-Control-Allow-Origin,*)`; + + promise_test((test) => { + return fetch(urlAllowCors, {'mode': 'no-cors'}).then((resp) => { + assert_equals(resp.status, 0, "Opaque filter: status is 0"); + assert_equals(resp.statusText, "", "Opaque filter: statusText is \"\""); + assert_equals(resp.type , "opaque", "Opaque filter: response's type is opaque"); + return resp.text().then((value) => { + assert_equals(value, "", "Opaque response should have an empty body"); + }); + }); + }, `${desc} [no-cors mode]`); + + promise_test((test) => { + return promise_rejects_js(test, TypeError, fetch(url, {'mode': 'cors'})); + }, `${desc} [server forbid CORS]`); + + promise_test((test) => { + return fetch(urlAllowCors, {'mode': 'cors'}).then((resp) => { + assert_equals(resp.status, 200, "Fetch's response's status is 200"); + assert_equals(resp.type , "cors", "CORS response's type is cors"); + }); + }, `${desc} [cors mode]`); +} + +cors('Same domain different port', HTTP_ORIGIN_WITH_DIFFERENT_PORT); +cors('Same domain different protocol different port', HTTPS_ORIGIN); +cors('Cross domain basic usage', HTTP_REMOTE_ORIGIN); +cors('Cross domain different port', HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT); +cors('Cross domain different protocol', HTTPS_REMOTE_ORIGIN); diff --git a/test/wpt/tests/fetch/api/cors/cors-cookies-redirect.any.js b/test/wpt/tests/fetch/api/cors/cors-cookies-redirect.any.js new file mode 100644 index 0000000..f5217b4 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-cookies-redirect.any.js @@ -0,0 +1,49 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +var redirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var urlSetCookies1 = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +var urlSetCookies2 = get_host_info().HTTP_ORIGIN_WITH_DIFFERENT_PORT + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +var urlCheckCookies = get_host_info().HTTP_ORIGIN_WITH_DIFFERENT_PORT + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=cookie"; + +var urlSetCookiesParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; +urlSetCookiesParameters += "|header(Access-Control-Allow-Credentials,true)"; + +urlSetCookiesParameters1 = urlSetCookiesParameters + "|header(Set-Cookie,a=1)"; +urlSetCookiesParameters2 = urlSetCookiesParameters + "|header(Set-Cookie,a=2)"; + +urlClearCookiesParameters1 = urlSetCookiesParameters + "|header(Set-Cookie,a=1%3B%20max-age=0)"; +urlClearCookiesParameters2 = urlSetCookiesParameters + "|header(Set-Cookie,a=2%3B%20max-age=0)"; + +promise_test(async (test) => { + await fetch(urlSetCookies1 + urlSetCookiesParameters1, {"credentials": "include", "mode": "cors"}); + await fetch(urlSetCookies2 + urlSetCookiesParameters2, {"credentials": "include", "mode": "cors"}); +}, "Set cookies"); + +function doTest(usePreflight) { + promise_test(async (test) => { + var url = redirectUrl; + var uuid_token = token(); + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=301"; + urlParameters += "&location=" + encodeURIComponent(urlCheckCookies); + urlParameters += "&allow_headers=a&headers=Cookie"; + headers = []; + if (usePreflight) + headers.push(["a", "b"]); + + var requestInit = {"credentials": "include", "mode": "cors", "headers": headers}; + var response = await fetch(url + urlParameters, requestInit); + + assert_equals(response.headers.get("x-request-cookie") , "a=2", "Request includes cookie(s)"); + }, "Testing credentials after cross-origin redirection with CORS and " + (usePreflight ? "" : "no ") + "preflight"); +} + +doTest(false); +doTest(true); + +promise_test(async (test) => { + await fetch(urlSetCookies1 + urlClearCookiesParameters1, {"credentials": "include", "mode": "cors"}); + await fetch(urlSetCookies2 + urlClearCookiesParameters2, {"credentials": "include", "mode": "cors"}); +}, "Clean cookies"); diff --git a/test/wpt/tests/fetch/api/cors/cors-cookies.any.js b/test/wpt/tests/fetch/api/cors/cors-cookies.any.js new file mode 100644 index 0000000..8c666e4 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-cookies.any.js @@ -0,0 +1,56 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsCookies(desc, baseURL1, baseURL2, credentialsMode, cookies) { + var urlSetCookie = baseURL1 + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; + var urlCheckCookies = baseURL2 + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=cookie"; + //enable cors with credentials + var urlParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; + urlParameters += "|header(Access-Control-Allow-Credentials,true)"; + + var urlCleanParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; + urlCleanParameters += "|header(Access-Control-Allow-Credentials,true)"; + if (cookies) { + urlParameters += "|header(Set-Cookie,"; + urlParameters += cookies.join(",True)|header(Set-Cookie,") + ",True)"; + urlCleanParameters += "|header(Set-Cookie,"; + urlCleanParameters += cookies.join("%3B%20max-age=0,True)|header(Set-Cookie,") + "%3B%20max-age=0,True)"; + } + + var requestInit = {"credentials": credentialsMode, "mode": "cors"}; + + promise_test(function(test){ + return fetch(urlSetCookie + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + //check cookies sent + return fetch(urlCheckCookies, requestInit); + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_false(resp.headers.has("Cookie") , "Cookie header is not exposed in response"); + if (credentialsMode === "include" && baseURL1 === baseURL2) { + assert_equals(resp.headers.get("x-request-cookie") , cookies.join("; "), "Request includes cookie(s)"); + } + else { + assert_false(resp.headers.has("x-request-cookie") , "Request should have no cookie"); + } + //clean cookies + return fetch(urlSetCookie + urlCleanParameters, {"credentials": "include"}); + }).catch(function(e) { + return fetch(urlSetCookie + urlCleanParameters, {"credentials": "include"}).then(function(resp) { + throw e; + }) + }); + }, desc); +} + +var local = get_host_info().HTTP_ORIGIN; +var remote = get_host_info().HTTP_REMOTE_ORIGIN; +// FIXME: otherRemote might not be accessible on some test environments. +var otherRemote = local.replace("http://", "http://www."); + +corsCookies("Omit mode: no cookie sent", local, local, "omit", ["g=7"]); +corsCookies("Include mode: 1 cookie", remote, remote, "include", ["a=1"]); +corsCookies("Include mode: local cookies are not sent with remote request", local, remote, "include", ["c=3"]); +corsCookies("Include mode: remote cookies are not sent with local request", remote, local, "include", ["d=4"]); +corsCookies("Same-origin mode: cookies are discarded in cors request", remote, remote, "same-origin", ["f=6"]); +corsCookies("Include mode: remote cookies are not sent with other remote request", remote, otherRemote, "include", ["e=5"]); diff --git a/test/wpt/tests/fetch/api/cors/cors-expose-star.sub.any.js b/test/wpt/tests/fetch/api/cors/cors-expose-star.sub.any.js new file mode 100644 index 0000000..340e99a --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-expose-star.sub.any.js @@ -0,0 +1,41 @@ +// META: script=../resources/utils.js + +const url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "top.txt", + sharedHeaders = "?pipe=header(Access-Control-Expose-Headers,*)|header(Test,X)|header(Set-Cookie,X)|header(*,whoa)|" + +promise_test(() => { + const headers = "header(Access-Control-Allow-Origin,*)" + return fetch(url + sharedHeaders + headers).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("test"), "X") + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "Basic Access-Control-Expose-Headers: * support") + +promise_test(() => { + const origin = location.origin, // assuming an ASCII origin + headers = "header(Access-Control-Allow-Origin," + origin + ")|header(Access-Control-Allow-Credentials,true)" + return fetch(url + sharedHeaders + headers, { credentials:"include" }).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("content-type"), "text/plain") // safelisted + assert_equals(resp.headers.get("test"), null) + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "* for credentialed fetches only matches literally") + +promise_test(() => { + const headers = "header(Access-Control-Allow-Origin,*)|header(Access-Control-Expose-Headers,set-cookie\\,*)" + return fetch(url + sharedHeaders + headers).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("test"), "X") + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "* can be one of several values") + +done(); diff --git a/test/wpt/tests/fetch/api/cors/cors-filtering.sub.any.js b/test/wpt/tests/fetch/api/cors/cors-filtering.sub.any.js new file mode 100644 index 0000000..a26eacc --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-filtering.sub.any.js @@ -0,0 +1,69 @@ +// META: script=../resources/utils.js + +function corsFilter(corsUrl, headerName, headerValue, isFiltered) { + var url = corsUrl + "?pipe=header(" + headerName + "," + encodeURIComponent(headerValue) +")|header(Access-Control-Allow-Origin,*)"; + promise_test(function(test) { + return fetch(url).then(function(resp) { + assert_equals(resp.status, 200, "Fetch success with code 200"); + assert_equals(resp.type , "cors", "CORS fetch's response has cors type"); + if (!isFiltered) { + assert_equals(resp.headers.get(headerName), headerValue, + headerName + " header should be included in response with value: " + headerValue); + } else { + assert_false(resp.headers.has(headerName), "UA should exclude " + headerName + " header from response"); + } + test.done(); + }); + }, "CORS filter on " + headerName + " header"); +} + +function corsExposeFilter(corsUrl, headerName, headerValue, isForbidden, withCredentials) { + var url = corsUrl + "?pipe=header(" + headerName + "," + encodeURIComponent(headerValue) +")|" + + "header(Access-Control-Allow-Origin, http://{{host}}:{{ports[http][0]}})" + + "header(Access-Control-Allow-Credentials, true)" + + "header(Access-Control-Expose-Headers," + headerName + ")"; + + var title = "CORS filter on " + headerName + " header, header is " + (isForbidden ? "forbidden" : "exposed"); + if (withCredentials) + title+= "(credentials = include)"; + promise_test(function(test) { + return fetch(new Request(url, { credentials: withCredentials ? "include" : "omit" })).then(function(resp) { + assert_equals(resp.status, 200, "Fetch success with code 200"); + assert_equals(resp.type , "cors", "CORS fetch's response has cors type"); + if (!isForbidden) { + assert_equals(resp.headers.get(headerName), headerValue, + headerName + " header should be included in response with value: " + headerValue); + } else { + assert_false(resp.headers.has(headerName), "UA should exclude " + headerName + " header from response"); + } + test.done(); + }); + }, title); +} + +var url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; + +corsFilter(url, "Cache-Control", "no-cache", false); +corsFilter(url, "Content-Language", "fr", false); +corsFilter(url, "Content-Type", "text/html", false); +corsFilter(url, "Expires","04 May 1988 22:22:22 GMT" , false); +corsFilter(url, "Last-Modified", "04 May 1988 22:22:22 GMT", false); +corsFilter(url, "Pragma", "no-cache", false); +corsFilter(url, "Content-Length", "3" , false); // top.txt contains "top" + +corsFilter(url, "Age", "27", true); +corsFilter(url, "Server", "wptServe" , true); +corsFilter(url, "Warning", "Mind the gap" , true); +corsFilter(url, "Set-Cookie", "name=value" , true); +corsFilter(url, "Set-Cookie2", "name=value" , true); + +corsExposeFilter(url, "Age", "27", false); +corsExposeFilter(url, "Server", "wptServe" , false); +corsExposeFilter(url, "Warning", "Mind the gap" , false); + +corsExposeFilter(url, "Set-Cookie", "name=value" , true); +corsExposeFilter(url, "Set-Cookie2", "name=value" , true); +corsExposeFilter(url, "Set-Cookie", "name=value" , true, true); +corsExposeFilter(url, "Set-Cookie2", "name=value" , true, true); + +done(); diff --git a/test/wpt/tests/fetch/api/cors/cors-keepalive.any.js b/test/wpt/tests/fetch/api/cors/cors-keepalive.any.js new file mode 100644 index 0000000..f68d90e --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-keepalive.any.js @@ -0,0 +1,118 @@ +// META: global=window +// META: timeout=long +// META: title=Fetch API: keepalive handling +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js +// META: script=../resources/utils.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTPS_ORIGIN, + HTTP_ORIGIN_WITH_DIFFERENT_PORT, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + HTTPS_REMOTE_ORIGIN, +} = get_host_info(); + +/** + * Tests to cover the basic behaviors of keepalive + cors/no-cors mode requests + * to different `origin` when the initiator document is still alive. They should + * behave the same as without setting keepalive. + */ +function keepaliveCorsBasicTest(desc, origin) { + const url = `${origin}${dirname(location.pathname)}${RESOURCES_DIR}top.txt`; + const urlAllowCors = `${url}?pipe=header(Access-Control-Allow-Origin,*)`; + + promise_test((test) => { + return fetch(urlAllowCors, {keepalive: true, 'mode': 'no-cors'}) + .then((resp) => { + assert_equals(resp.status, 0, 'Opaque filter: status is 0'); + assert_equals(resp.statusText, '', 'Opaque filter: statusText is ""'); + assert_equals( + resp.type, 'opaque', 'Opaque filter: response\'s type is opaque'); + return resp.text().then((value) => { + assert_equals( + value, '', 'Opaque response should have an empty body'); + }); + }); + }, `${desc} [no-cors mode]`); + + promise_test((test) => { + return promise_rejects_js( + test, TypeError, fetch(url, {keepalive: true, 'mode': 'cors'})); + }, `${desc} [cors mode, server forbid CORS]`); + + promise_test((test) => { + return fetch(urlAllowCors, {keepalive: true, 'mode': 'cors'}) + .then((resp) => { + assert_equals(resp.status, 200, 'Fetch\'s response\'s status is 200'); + assert_equals(resp.type, 'cors', 'CORS response\'s type is cors'); + }); + }, `${desc} [cors mode]`); +} + +keepaliveCorsBasicTest( + `[keepalive] Same domain different port`, HTTP_ORIGIN_WITH_DIFFERENT_PORT); +keepaliveCorsBasicTest( + `[keepalive] Same domain different protocol different port`, HTTPS_ORIGIN); +keepaliveCorsBasicTest( + `[keepalive] Cross domain basic usage`, HTTP_REMOTE_ORIGIN); +keepaliveCorsBasicTest( + `[keepalive] Cross domain different port`, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT); +keepaliveCorsBasicTest( + `[keepalive] Cross domain different protocol`, HTTPS_REMOTE_ORIGIN); + +/** + * In a same-site iframe, and in `unload` event handler, test to fetch + * a keepalive URL that involves in different cors modes. + */ +function keepaliveCorsInUnloadTest(description, origin, method) { + const evt = 'unload'; + for (const mode of ['no-cors', 'cors']) { + for (const disallowOrigin of [false, true]) { + const desc = `${description} ${method} request in ${evt} [${mode} mode` + + (disallowOrigin ? `, server forbid CORS]` : `]`); + const shouldPass = !disallowOrigin || mode === 'no-cors'; + promise_test(async (test) => { + const token1 = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveIframeUrl(token1, method, { + frameOrigin: '', + requestOrigin: origin, + sendOn: evt, + mode: mode, + disallowOrigin + }); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + iframe.remove(); + assert_equals(await getTokenFromMessage(), token1); + + assertStashedTokenAsync(desc, token1, {shouldPass}); + }, `${desc}; setting up`); + } + } +} + +for (const method of ['GET', 'POST']) { + keepaliveCorsInUnloadTest( + '[keepalive] Same domain different port', HTTP_ORIGIN_WITH_DIFFERENT_PORT, + method); + keepaliveCorsInUnloadTest( + `[keepalive] Same domain different protocol different port`, HTTPS_ORIGIN, + method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain basic usage`, HTTP_REMOTE_ORIGIN, method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain different port`, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain different protocol`, HTTPS_REMOTE_ORIGIN, + method); +} diff --git a/test/wpt/tests/fetch/api/cors/cors-multiple-origins.sub.any.js b/test/wpt/tests/fetch/api/cors/cors-multiple-origins.sub.any.js new file mode 100644 index 0000000..b3abb92 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-multiple-origins.sub.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function corsMultipleOrigins(originList) { + var urlParameters = "?origin=" + encodeURIComponent(originList.join(", ")); + var url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters)); + }, "Listing multiple origins is illegal: " + originList); +} +/* Actual origin */ +var origin = "http://{{host}}:{{ports[http][0]}}"; + +corsMultipleOrigins(["\"\"", "http://example.com", origin]); +corsMultipleOrigins(["\"\"", "http://example.com", "*"]); +corsMultipleOrigins(["\"\"", origin, origin]); +corsMultipleOrigins(["*", "http://example.com", "*"]); +corsMultipleOrigins(["*", "http://example.com", origin]); +corsMultipleOrigins(["", "http://example.com", "https://example2.com"]); + +done(); diff --git a/test/wpt/tests/fetch/api/cors/cors-no-preflight.any.js b/test/wpt/tests/fetch/api/cors/cors-no-preflight.any.js new file mode 100644 index 0000000..7a0269a --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-no-preflight.any.js @@ -0,0 +1,41 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsNoPreflight(desc, baseURL, method, headerName, headerValue) { + + var uuid_token = token(); + var url = baseURL + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "method": method, "headers":{}}; + if (headerName) + requestInit["headers"][headerName] = headerValue; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "No preflight request has been made"); + }); + }); + }, desc); +} + +var host_info = get_host_info(); + +corsNoPreflight("Cross domain basic usage [GET]", host_info.HTTP_REMOTE_ORIGIN, "GET"); +corsNoPreflight("Same domain different port [GET]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET"); +corsNoPreflight("Cross domain different port [GET]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET"); +corsNoPreflight("Cross domain different protocol [GET]", host_info.HTTPS_REMOTE_ORIGIN, "GET"); +corsNoPreflight("Same domain different protocol different port [GET]", host_info.HTTPS_ORIGIN, "GET"); +corsNoPreflight("Cross domain [POST]", host_info.HTTP_REMOTE_ORIGIN, "POST"); +corsNoPreflight("Cross domain [HEAD]", host_info.HTTP_REMOTE_ORIGIN, "HEAD"); +corsNoPreflight("Cross domain [GET] [Accept: */*]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Accept", "*/*"); +corsNoPreflight("Cross domain [GET] [Accept-Language: fr]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Accept-Language", "fr"); +corsNoPreflight("Cross domain [GET] [Content-Language: fr]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Language", "fr"); +corsNoPreflight("Cross domain [GET] [Content-Type: application/x-www-form-urlencoded]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "application/x-www-form-urlencoded"); +corsNoPreflight("Cross domain [GET] [Content-Type: multipart/form-data]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "multipart/form-data"); +corsNoPreflight("Cross domain [GET] [Content-Type: text/plain]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "text/plain"); +corsNoPreflight("Cross domain [GET] [Content-Type: text/plain;charset=utf-8]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "text/plain;charset=utf-8"); +corsNoPreflight("Cross domain [GET] [Content-Type: Text/Plain;charset=utf-8]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "Text/Plain;charset=utf-8"); diff --git a/test/wpt/tests/fetch/api/cors/cors-origin.any.js b/test/wpt/tests/fetch/api/cors/cors-origin.any.js new file mode 100644 index 0000000..30a02d9 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-origin.any.js @@ -0,0 +1,51 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +/* If origin is undefined, it is set to fetched url's origin*/ +function corsOrigin(desc, baseURL, method, origin, shouldPass) { + if (!origin) + origin = baseURL; + + var uuid_token = token(); + var urlParameters = "?token=" + uuid_token + "&max_age=0&origin=" + encodeURIComponent(origin) + "&allow_methods=" + method; + var url = baseURL + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + var requestInit = {"mode": "cors", "method": method}; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + if (shouldPass) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + } + }); + }, desc); + +} + +var host_info = get_host_info(); + +/* Actual origin */ +var origin = host_info.HTTP_ORIGIN; + +corsOrigin("Cross domain different subdomain [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "GET", origin, true); +corsOrigin("Cross domain different subdomain [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "GET", undefined, false); +corsOrigin("Same domain different port [origin OK]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET", origin, true); +corsOrigin("Same domain different port [origin KO]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET", undefined, false); +corsOrigin("Cross domain different port [origin OK]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET", origin, true); +corsOrigin("Cross domain different port [origin KO]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET", undefined, false); +corsOrigin("Cross domain different protocol [origin OK]", host_info.HTTPS_REMOTE_ORIGIN, "GET", origin, true); +corsOrigin("Cross domain different protocol [origin KO]", host_info.HTTPS_REMOTE_ORIGIN, "GET", undefined, false); +corsOrigin("Same domain different protocol different port [origin OK]", host_info.HTTPS_ORIGIN, "GET", origin, true); +corsOrigin("Same domain different protocol different port [origin KO]", host_info.HTTPS_ORIGIN, "GET", undefined, false); +corsOrigin("Cross domain [POST] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "POST", origin, true); +corsOrigin("Cross domain [POST] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "POST", undefined, false); +corsOrigin("Cross domain [HEAD] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "HEAD", origin, true); +corsOrigin("Cross domain [HEAD] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "HEAD", undefined, false); +corsOrigin("CORS preflight [PUT] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "PUT", origin, true); +corsOrigin("CORS preflight [PUT] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "PUT", undefined, false); +corsOrigin("Allowed origin: \"\" [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "GET", "" , false); diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-cache.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-cache.any.js new file mode 100644 index 0000000..ce6a169 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-cache.any.js @@ -0,0 +1,46 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +var cors_url = get_host_info().HTTP_REMOTE_ORIGIN + + dirname(location.pathname) + + RESOURCES_DIR + + "preflight.py"; + +promise_test((test) => { + var uuid_token = token(); + var request_url = + cors_url + "?token=" + uuid_token + "&max_age=12000&allow_methods=POST" + + "&allow_headers=x-test-header"; + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash") + .then(() => { + return fetch( + new Request(request_url, + { + mode: "cors", + method: "POST", + headers: [["x-test-header", "test1"]] + })); + }) + .then((resp) => { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash"); + }) + .then((res) => res.text()) + .then((txt) => { + assert_equals(txt, "1", "Server stash must be cleared."); + return fetch( + new Request(request_url, + { + mode: "cors", + method: "POST", + headers: [["x-test-header", "test2"]] + })); + }) + .then((resp) => { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "Preflight request has not been made"); + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash"); + }); +}); diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js new file mode 100644 index 0000000..b2747cc --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js @@ -0,0 +1,19 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/corspreflight.js + +const corsURL = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +promise_test(() => fetch("resources/not-cors-safelisted.json").then(res => res.json().then(runTests)), "Loading data…"); + +function runTests(testArray) { + testArray.forEach(testItem => { + const [headerName, headerValue] = testItem; + corsPreflight("Need CORS-preflight for " + headerName + "/" + headerValue + " header", + corsURL, + "GET", + true, + [[headerName, headerValue]]); + }); +} diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-redirect.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-redirect.any.js new file mode 100644 index 0000000..15f7659 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-redirect.any.js @@ -0,0 +1,37 @@ +// META: global=window,worker +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightRedirect(desc, redirectUrl, redirectLocation, redirectStatus, redirectPreflight) { + var uuid_token = token(); + var url = redirectUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + if (redirectPreflight) + urlParameters += "&redirect_preflight"; + var requestInit = {"mode": "cors", "redirect": "follow"}; + + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + urlParameters += "&allow_headers=x-force-preflight"; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + }); + }, desc); +} + +var redirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +for (var code of [301, 302, 303, 307, 308]) { + /* preflight should not follow the redirection */ + corsPreflightRedirect("Redirection " + code + " on preflight failed", redirectUrl, locationUrl, code, true); + /* preflight is done before redirection: preflight force redirect to error */ + corsPreflightRedirect("Redirection " + code + " after preflight failed", redirectUrl, locationUrl, code, false); +} diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-referrer.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-referrer.any.js new file mode 100644 index 0000000..5df9fcf --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-referrer.any.js @@ -0,0 +1,51 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightReferrer(desc, corsUrl, referrerPolicy, referrer, expectedReferrer) { + var uuid_token = token(); + var url = corsUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "referrerPolicy": referrerPolicy}; + + if (referrer) + requestInit.referrer = referrer; + + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + urlParameters += "&allow_headers=x-force-preflight"; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + assert_equals(resp.headers.get("x-preflight-referrer"), expectedReferrer, "Preflight's referrer is correct"); + assert_equals(resp.headers.get("x-referrer"), expectedReferrer, "Request's referrer is correct"); + assert_equals(resp.headers.get("x-control-request-headers"), "", "Access-Control-Allow-Headers value"); + }); + }); + }, desc + " and referrer: " + (referrer ? "'" + referrer + "'" : "default")); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +var origin = get_host_info().HTTP_ORIGIN + "/"; + +corsPreflightReferrer("Referrer policy: no-referrer", corsUrl, "no-referrer", undefined, ""); +corsPreflightReferrer("Referrer policy: no-referrer", corsUrl, "no-referrer", "myreferrer", ""); + +corsPreflightReferrer("Referrer policy: \"\"", corsUrl, "", undefined, origin); +corsPreflightReferrer("Referrer policy: \"\"", corsUrl, "", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: no-referrer-when-downgrade", corsUrl, "no-referrer-when-downgrade", undefined, location.toString()) +corsPreflightReferrer("Referrer policy: no-referrer-when-downgrade", corsUrl, "no-referrer-when-downgrade", "myreferrer", new URL("myreferrer", location).toString()); + +corsPreflightReferrer("Referrer policy: origin", corsUrl, "origin", undefined, origin); +corsPreflightReferrer("Referrer policy: origin", corsUrl, "origin", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: origin-when-cross-origin", corsUrl, "origin-when-cross-origin", undefined, origin); +corsPreflightReferrer("Referrer policy: origin-when-cross-origin", corsUrl, "origin-when-cross-origin", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: unsafe-url", corsUrl, "unsafe-url", undefined, location.toString()); +corsPreflightReferrer("Referrer policy: unsafe-url", corsUrl, "unsafe-url", "myreferrer", new URL("myreferrer", location).toString()); diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-response-validation.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-response-validation.any.js new file mode 100644 index 0000000..718e351 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-response-validation.any.js @@ -0,0 +1,33 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightResponseValidation(desc, corsUrl, allowHeaders, allowMethods) { + var uuid_token = token(); + var url = corsUrl; + var requestInit = {"mode": "cors"}; + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&allow_headers=x-force-preflight"; + if (allowHeaders) + urlParameters += "," + allowHeaders; + if (allowMethods) + urlParameters += "&allow_methods="+ allowMethods; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(async function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + await promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + + return fetch(url + urlParameters).then(function(resp) { + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + }); + }, desc); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +corsPreflightResponseValidation("Preflight response with a bad Access-Control-Allow-Headers", corsUrl, "Bad value", null); +corsPreflightResponseValidation("Preflight response with a bad Access-Control-Allow-Methods", corsUrl, null, "Bad value"); diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-star.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-star.any.js new file mode 100644 index 0000000..f9fb204 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-star.any.js @@ -0,0 +1,86 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const url = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py", + origin = location.origin // assuming an ASCII origin + +function preflightTest(succeeds, withCredentials, allowMethod, allowHeader, useMethod, useHeader) { + return promise_test(t => { + let testURL = url + "?", + requestInit = {} + if (withCredentials) { + testURL += "origin=" + origin + "&" + testURL += "credentials&" + requestInit.credentials = "include" + } + if (useMethod) { + requestInit.method = useMethod + } + if (useHeader.length > 0) { + requestInit.headers = [useHeader] + } + testURL += "allow_methods=" + allowMethod + "&" + testURL += "allow_headers=" + allowHeader + "&" + + if (succeeds) { + return fetch(testURL, requestInit).then(resp => { + assert_equals(resp.headers.get("x-origin"), origin) + }) + } else { + return promise_rejects_js(t, TypeError, fetch(testURL, requestInit)) + } + }, "CORS that " + (succeeds ? "succeeds" : "fails") + " with credentials: " + withCredentials + "; method: " + useMethod + " (allowed: " + allowMethod + "); header: " + useHeader + " (allowed: " + allowHeader + ")") +} + +// "GET" does not pass the case-sensitive method check, but in the safe list. +preflightTest(true, false, "get", "x-test", "GET", ["X-Test", "1"]) +// Headers check is case-insensitive, and "*" works as any for method. +preflightTest(true, false, "*", "x-test", "SUPER", ["X-Test", "1"]) +// "*" works as any only without credentials. +preflightTest(true, false, "*", "*", "OK", ["X-Test", "1"]) +preflightTest(false, true, "*", "*", "OK", ["X-Test", "1"]) +preflightTest(false, true, "*", "", "PUT", []) +preflightTest(false, true, "get", "*", "GET", ["X-Test", "1"]) +preflightTest(false, true, "*", "*", "GET", ["X-Test", "1"]) +// Exact character match works even for "*" with credentials. +preflightTest(true, true, "*", "*", "*", ["*", "1"]) + +// The following methods are upper-cased for init["method"] by +// https://fetch.spec.whatwg.org/#concept-method-normalize +// but not in Access-Control-Allow-Methods response. +// But they are https://fetch.spec.whatwg.org/#cors-safelisted-method, +// CORS anyway passes regardless of the cases. +for (const METHOD of ['GET', 'HEAD', 'POST']) { + const method = METHOD.toLowerCase(); + preflightTest(true, true, METHOD, "*", METHOD, []) + preflightTest(true, true, METHOD, "*", method, []) + preflightTest(true, true, method, "*", METHOD, []) + preflightTest(true, true, method, "*", method, []) +} + +// The following methods are upper-cased for init["method"] by +// https://fetch.spec.whatwg.org/#concept-method-normalize +// but not in Access-Control-Allow-Methods response. +// As they are not https://fetch.spec.whatwg.org/#cors-safelisted-method, +// Access-Control-Allow-Methods should contain upper-cased methods, +// while init["method"] can be either in upper or lower case. +for (const METHOD of ['DELETE', 'PUT']) { + const method = METHOD.toLowerCase(); + preflightTest(true, true, METHOD, "*", METHOD, []) + preflightTest(true, true, METHOD, "*", method, []) + preflightTest(false, true, method, "*", METHOD, []) + preflightTest(false, true, method, "*", method, []) +} + +// "PATCH" is NOT upper-cased in both places because it is not listed in +// https://fetch.spec.whatwg.org/#concept-method-normalize. +// So Access-Control-Allow-Methods value and init["method"] should match +// case-sensitively. +preflightTest(true, true, "PATCH", "*", "PATCH", []) +preflightTest(false, true, "PATCH", "*", "patch", []) +preflightTest(false, true, "patch", "*", "PATCH", []) +preflightTest(true, true, "patch", "*", "patch", []) + +// "Authorization" header can't be wildcarded. +preflightTest(false, false, "*", "*", "POST", ["Authorization", "123"]) +preflightTest(true, false, "*", "*, Authorization", "POST", ["Authorization", "123"]) diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight-status.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight-status.any.js new file mode 100644 index 0000000..a4467a6 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight-status.any.js @@ -0,0 +1,37 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +/* Check preflight is ok if status is ok status (200 to 299)*/ +function corsPreflightStatus(desc, corsUrl, preflightStatus) { + var uuid_token = token(); + var url = corsUrl; + var requestInit = {"mode": "cors"}; + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&allow_headers=x-force-preflight"; + urlParameters += "&preflight_status=" + preflightStatus; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + if (200 <= preflightStatus && 299 >= preflightStatus) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + } + }); + }, desc); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +for (status of [200, 201, 202, 203, 204, 205, 206, + 300, 301, 302, 303, 304, 305, 306, 307, 308, + 400, 401, 402, 403, 404, 405, + 501, 502, 503, 504, 505]) + corsPreflightStatus("Preflight answered with status " + status, corsUrl, status); diff --git a/test/wpt/tests/fetch/api/cors/cors-preflight.any.js b/test/wpt/tests/fetch/api/cors/cors-preflight.any.js new file mode 100644 index 0000000..045422f --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-preflight.any.js @@ -0,0 +1,62 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/corspreflight.js + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +corsPreflight("CORS [DELETE], server allows", corsUrl, "DELETE", true); +corsPreflight("CORS [DELETE], server refuses", corsUrl, "DELETE", false); +corsPreflight("CORS [PUT], server allows", corsUrl, "PUT", true); +corsPreflight("CORS [PUT], server allows, check preflight has user agent", corsUrl + "?checkUserAgentHeaderInPreflight", "PUT", true); +corsPreflight("CORS [PUT], server refuses", corsUrl, "PUT", false); +corsPreflight("CORS [PATCH], server allows", corsUrl, "PATCH", true); +corsPreflight("CORS [PATCH], server refuses", corsUrl, "PATCH", false); +corsPreflight("CORS [patcH], server allows", corsUrl, "patcH", true); +corsPreflight("CORS [patcH], server refuses", corsUrl, "patcH", false); +corsPreflight("CORS [NEW], server allows", corsUrl, "NEW", true); +corsPreflight("CORS [NEW], server refuses", corsUrl, "NEW", false); +corsPreflight("CORS [chicken], server allows", corsUrl, "chicken", true); +corsPreflight("CORS [chicken], server refuses", corsUrl, "chicken", false); + +corsPreflight("CORS [GET] [x-test-header: allowed], server allows", corsUrl, "GET", true, [["x-test-header1", "allowed"]]); +corsPreflight("CORS [GET] [x-test-header: refused], server refuses", corsUrl, "GET", false, [["x-test-header1", "refused"]]); + +var headers = [ + ["x-test-header1", "allowedOrRefused"], + ["x-test-header2", "allowedOrRefused"], + ["X-test-header3", "allowedOrRefused"], + ["x-test-header-b", "allowedOrRefused"], + ["x-test-header-D", "allowedOrRefused"], + ["x-test-header-C", "allowedOrRefused"], + ["x-test-header-a", "allowedOrRefused"], + ["Content-Type", "allowedOrRefused"], +]; +var safeHeaders= [ + ["Accept", "*"], + ["Accept-Language", "bzh"], + ["Content-Language", "eu"], +]; + +corsPreflight("CORS [GET] [several headers], server allows", corsUrl, "GET", true, headers, safeHeaders); +corsPreflight("CORS [GET] [several headers], server refuses", corsUrl, "GET", false, headers, safeHeaders); +corsPreflight("CORS [PUT] [several headers], server allows", corsUrl, "PUT", true, headers, safeHeaders); +corsPreflight("CORS [PUT] [several headers], server refuses", corsUrl, "PUT", false, headers, safeHeaders); + +corsPreflight("CORS [PUT] [only safe headers], server allows", corsUrl, "PUT", true, null, safeHeaders); + +promise_test(async t => { + const url = `${corsUrl}?allow_headers=*`; + await promise_rejects_js(t, TypeError, fetch(url, { + headers: { + authorization: 'foobar' + } + })); +}, '"authorization" should not be covered by the wildcard symbol'); + +promise_test(async t => { + const url = `${corsUrl}?allow_headers=authorization`; + await fetch(url, { headers: { + authorization: 'foobar' + }}); +}, '"authorization" should be covered by "authorization"'); \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/cors/cors-redirect-credentials.any.js b/test/wpt/tests/fetch/api/cors/cors-redirect-credentials.any.js new file mode 100644 index 0000000..2aff313 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-redirect-credentials.any.js @@ -0,0 +1,52 @@ +// META: timeout=long +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirectCredentials(desc, redirectUrl, redirectLocation, redirectStatus, locationCredentials) { + var url = redirectUrl + var urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + redirectLocation.replace("://", "://" + locationCredentials + "@"); + + var requestInit = {"mode": "cors", "redirect": "follow"}; + + promise_test(t => { + const result = fetch(url + urlParameters, requestInit) + if(locationCredentials === "") { + return result; + } else { + return promise_rejects_js(t, TypeError, result); + } + }, desc); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; +var remoteLocation2 = host_info.HTTP_REMOTE_ORIGIN + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirectCredentials("Redirect " + code + " from same origin to remote without user and password", localRedirect, remoteLocation, code, ""); + + corsRedirectCredentials("Redirect " + code + " from same origin to remote with user and password", localRedirect, remoteLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from same origin to remote with user", localRedirect, remoteLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from same origin to remote with password", localRedirect, remoteLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to same origin with user and password", remoteRedirect, localLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to same origin with user", remoteRedirect, localLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to same origin with password", remoteRedirect, localLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to same remote with user and password", remoteRedirect, remoteLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to same remote with user", remoteRedirect, remoteLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to same remote with password", remoteRedirect, remoteLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to another remote with user and password", remoteRedirect, remoteLocation2, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to another remote with user", remoteRedirect, remoteLocation2, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to another remote with password", remoteRedirect, remoteLocation2, code, ":password"); +} diff --git a/test/wpt/tests/fetch/api/cors/cors-redirect-preflight.any.js b/test/wpt/tests/fetch/api/cors/cors-redirect-preflight.any.js new file mode 100644 index 0000000..5084817 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-redirect-preflight.any.js @@ -0,0 +1,46 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirect(desc, redirectUrl, redirectLocation, redirectStatus, expectSuccess) { + var urlBaseParameters = "&redirect_status=" + redirectStatus; + var urlParametersSuccess = urlBaseParameters + "&allow_headers=x-w3c&location=" + encodeURIComponent(redirectLocation + "?allow_headers=x-w3c"); + var urlParametersFailure = urlBaseParameters + "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {"mode": "cors", "redirect": "follow", "headers" : [["x-w3c", "test"]]}; + + promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return fetch(redirectUrl + "?token=" + uuid_token + "&max_age=0" + urlParametersSuccess, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + }); + }, desc + " (preflight after redirection success case)"); + promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return promise_rejects_js(test, TypeError, fetch(redirectUrl + "?token=" + uuid_token + "&max_age=0" + urlParametersFailure, requestInit)); + }); + }, desc + " (preflight after redirection failure case)"); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_REMOTE_ORIGIN + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_REMOTE_ORIGIN + preflightPath; +var remoteLocation2 = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirect("Redirect " + code + ": same origin to cors", localRedirect, remoteLocation, code); + corsRedirect("Redirect " + code + ": cors to same origin", remoteRedirect, localLocation, code); + corsRedirect("Redirect " + code + ": cors to another cors", remoteRedirect, remoteLocation2, code); +} diff --git a/test/wpt/tests/fetch/api/cors/cors-redirect.any.js b/test/wpt/tests/fetch/api/cors/cors-redirect.any.js new file mode 100644 index 0000000..cdf4097 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/cors-redirect.any.js @@ -0,0 +1,42 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirect(desc, redirectUrl, redirectLocation, redirectStatus, expectedOrigin) { + var uuid_token = token(); + var url = redirectUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {"mode": "cors", "redirect": "follow"}; + + return promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "No preflight request has been made"); + assert_equals(resp.headers.get("x-origin"), expectedOrigin, "Origin is correctly set after redirect"); + }); + }); + }, desc); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_REMOTE_ORIGIN + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_REMOTE_ORIGIN + preflightPath; +var remoteLocation2 = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirect("Redirect " + code + ": cors to same cors", remoteRedirect, remoteLocation, code, location.origin); + corsRedirect("Redirect " + code + ": cors to another cors", remoteRedirect, remoteLocation2, code, "null"); + corsRedirect("Redirect " + code + ": same origin to cors", localRedirect, remoteLocation, code, location.origin); + corsRedirect("Redirect " + code + ": cors to same origin", remoteRedirect, localLocation, code, "null"); +} diff --git a/test/wpt/tests/fetch/api/cors/data-url-iframe.html b/test/wpt/tests/fetch/api/cors/data-url-iframe.html new file mode 100644 index 0000000..217baa3 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/data-url-iframe.html @@ -0,0 +1,58 @@ + + + + + + diff --git a/test/wpt/tests/fetch/api/cors/data-url-shared-worker.html b/test/wpt/tests/fetch/api/cors/data-url-shared-worker.html new file mode 100644 index 0000000..d69748a --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/data-url-shared-worker.html @@ -0,0 +1,53 @@ + + + + + diff --git a/test/wpt/tests/fetch/api/cors/data-url-worker.html b/test/wpt/tests/fetch/api/cors/data-url-worker.html new file mode 100644 index 0000000..13113e6 --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/data-url-worker.html @@ -0,0 +1,50 @@ + + + + + diff --git a/test/wpt/tests/fetch/api/cors/resources/corspreflight.js b/test/wpt/tests/fetch/api/cors/resources/corspreflight.js new file mode 100644 index 0000000..18b8f6d --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/resources/corspreflight.js @@ -0,0 +1,58 @@ +function headerNames(headers) { + let names = []; + for (let header of headers) { + names.push(header[0].toLowerCase()); + } + return names; +} + +/* + Check preflight is done + Control if server allows method and headers and check accordingly + Check control access headers added by UA (for method and headers) +*/ +function corsPreflight(desc, corsUrl, method, allowed, headers, safeHeaders) { + return promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(response) { + var url = corsUrl + (corsUrl.indexOf("?") === -1 ? "?" : "&"); + var urlParameters = "token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "method": method}; + var requestHeaders = []; + if (headers) + requestHeaders.push.apply(requestHeaders, headers); + if (safeHeaders) + requestHeaders.push.apply(requestHeaders, safeHeaders); + requestInit["headers"] = requestHeaders; + + if (allowed) { + urlParameters += "&allow_methods=" + method + "&control_request_headers"; + if (headers) { + //Make the server allow the headers + urlParameters += "&allow_headers=" + headerNames(headers).join("%20%2C"); + } + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + if (headers) { + var actualHeaders = resp.headers.get("x-control-request-headers").toLowerCase().split(","); + for (var i in actualHeaders) + actualHeaders[i] = actualHeaders[i].trim(); + for (var header of headers) + assert_in_array(header[0].toLowerCase(), actualHeaders, "Preflight asked permission for header: " + header); + + let accessControlAllowHeaders = headerNames(headers).sort().join(","); + assert_equals(resp.headers.get("x-control-request-headers"), accessControlAllowHeaders, "Access-Control-Allow-Headers value"); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token); + } else { + assert_equals(resp.headers.get("x-control-request-headers"), null, "Access-Control-Request-Headers should be omitted") + } + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)).then(function(){ + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token); + }); + } + }); + }, desc); +} diff --git a/test/wpt/tests/fetch/api/cors/resources/not-cors-safelisted.json b/test/wpt/tests/fetch/api/cors/resources/not-cors-safelisted.json new file mode 100644 index 0000000..945dc0f --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/resources/not-cors-safelisted.json @@ -0,0 +1,13 @@ +[ + ["accept", "\""], + ["accept", "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"], + ["accept-language", "\u0001"], + ["accept-language", "@"], + ["authorization", "basics"], + ["content-language", "\u0001"], + ["content-language", "@"], + ["content-type", "text/html"], + ["content-type", "text/plain; long=0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901"], + ["range", "bytes 0-"], + ["test", "hi"] +] diff --git a/test/wpt/tests/fetch/api/cors/sandboxed-iframe.html b/test/wpt/tests/fetch/api/cors/sandboxed-iframe.html new file mode 100644 index 0000000..feb9f1f --- /dev/null +++ b/test/wpt/tests/fetch/api/cors/sandboxed-iframe.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/api/crashtests/body-window-destroy.html b/test/wpt/tests/fetch/api/crashtests/body-window-destroy.html new file mode 100644 index 0000000..646d3c5 --- /dev/null +++ b/test/wpt/tests/fetch/api/crashtests/body-window-destroy.html @@ -0,0 +1,11 @@ + + + diff --git a/test/wpt/tests/fetch/api/crashtests/request.html b/test/wpt/tests/fetch/api/crashtests/request.html new file mode 100644 index 0000000..2d21930 --- /dev/null +++ b/test/wpt/tests/fetch/api/crashtests/request.html @@ -0,0 +1,8 @@ + + + + diff --git a/test/wpt/tests/fetch/api/credentials/authentication-basic.any.js b/test/wpt/tests/fetch/api/credentials/authentication-basic.any.js new file mode 100644 index 0000000..31ccc38 --- /dev/null +++ b/test/wpt/tests/fetch/api/credentials/authentication-basic.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +function basicAuth(desc, user, pass, mode, status) { + promise_test(function(test) { + var headers = { "Authorization": "Basic " + btoa(user + ":" + pass)}; + var requestInit = {"credentials": mode, "headers": headers}; + return fetch("../resources/authentication.py?realm=test", requestInit).then(function(resp) { + assert_equals(resp.status, status, "HTTP status is " + status); + assert_equals(resp.type , "basic", "Response's type is basic"); + }); + }, desc); +} + +basicAuth("User-added Authorization header with include mode", "user", "password", "include", 200); +basicAuth("User-added Authorization header with same-origin mode", "user", "password", "same-origin", 200); +basicAuth("User-added Authorization header with omit mode", "user", "password", "omit", 200); +basicAuth("User-added bogus Authorization header with omit mode", "notuser", "notpassword", "omit", 401); diff --git a/test/wpt/tests/fetch/api/credentials/authentication-redirection.any.js b/test/wpt/tests/fetch/api/credentials/authentication-redirection.any.js new file mode 100644 index 0000000..16656b5 --- /dev/null +++ b/test/wpt/tests/fetch/api/credentials/authentication-redirection.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const authorizationValue = "Basic " + btoa("user:pass"); +async function getAuthorizationHeaderValue(url) +{ + const headers = { "Authorization": authorizationValue}; + const requestInit = {"headers": headers}; + const response = await fetch(url, requestInit); + return response.text(); +} + +promise_test(async test => { + const result = await getAuthorizationHeaderValue("/fetch/api/resources/dump-authorization-header.py"); + assert_equals(result, authorizationValue); +}, "getAuthorizationHeaderValue - no redirection"); + +promise_test(async test => { + result = await getAuthorizationHeaderValue("/fetch/api/resources/redirect.py?location=" + encodeURIComponent("/fetch/api/resources/dump-authorization-header.py")); + assert_equals(result, authorizationValue); + + result = await getAuthorizationHeaderValue(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py?allow_headers=Authorization&location=" + encodeURIComponent(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/dump-authorization-header.py")); + assert_equals(result, authorizationValue); +}, "getAuthorizationHeaderValue - same origin redirection"); + +promise_test(async (test) => { + const result = await getAuthorizationHeaderValue(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py?allow_headers=Authorization&location=" + encodeURIComponent(get_host_info().HTTPS_ORIGIN + "/fetch/api/resources/dump-authorization-header.py")); + assert_equals(result, "none"); +}, "getAuthorizationHeaderValue - cross origin redirection"); diff --git a/test/wpt/tests/fetch/api/credentials/cookies.any.js b/test/wpt/tests/fetch/api/credentials/cookies.any.js new file mode 100644 index 0000000..de30e47 --- /dev/null +++ b/test/wpt/tests/fetch/api/credentials/cookies.any.js @@ -0,0 +1,49 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function cookies(desc, credentials1, credentials2 ,cookies) { + var url = RESOURCES_DIR + "top.txt" + var urlParameters = ""; + var urlCleanParameters = ""; + if (cookies) { + urlParameters +="?pipe=header(Set-Cookie,"; + urlParameters += cookies.join(",True)|header(Set-Cookie,") + ",True)"; + urlCleanParameters +="?pipe=header(Set-Cookie,"; + urlCleanParameters += cookies.join("%3B%20max-age=0,True)|header(Set-Cookie,") + "%3B%20max-age=0,True)"; + } + + var requestInit = {"credentials": credentials1} + promise_test(function(test){ + var requestInit = {"credentials": credentials1} + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + //check cookies sent + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=cookie" , {"credentials": credentials2}); + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_false(resp.headers.has("Cookie") , "Cookie header is not exposed in response"); + if (credentials1 != "omit" && credentials2 != "omit") { + assert_equals(resp.headers.get("x-request-cookie") , cookies.join("; "), "Request include cookie(s)"); + } + else { + assert_false(resp.headers.has("x-request-cookie") , "Request does not have cookie(s)"); + } + //clean cookies + return fetch(url + urlCleanParameters, {"credentials": "include"}); + }).catch(function(e) { + return fetch(url + urlCleanParameters, {"credentials": "include"}).then(function() { + return Promise.reject(e); + }); + }); + }, desc); +} + +cookies("Include mode: 1 cookie", "include", "include", ["a=1"]); +cookies("Include mode: 2 cookies", "include", "include", ["b=2", "c=3"]); +cookies("Omit mode: discard cookies", "omit", "omit", ["d=4"]); +cookies("Omit mode: no cookie is stored", "omit", "include", ["e=5"]); +cookies("Omit mode: no cookie is sent", "include", "omit", ["f=6"]); +cookies("Same-origin mode: 1 cookie", "same-origin", "same-origin", ["a=1"]); +cookies("Same-origin mode: 2 cookies", "same-origin", "same-origin", ["b=2", "c=3"]); diff --git a/test/wpt/tests/fetch/api/headers/header-setcookie.any.js b/test/wpt/tests/fetch/api/headers/header-setcookie.any.js new file mode 100644 index 0000000..cafb780 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/header-setcookie.any.js @@ -0,0 +1,266 @@ +// META: title=Headers set-cookie special cases +// META: global=window,worker + +const headerList = [ + ["set-cookie", "foo=bar"], + ["Set-Cookie", "fizz=buzz; domain=example.com"], +]; + +const setCookie2HeaderList = [ + ["set-cookie2", "foo2=bar2"], + ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"], +]; + +function assert_nested_array_equals(actual, expected) { + assert_equals(actual.length, expected.length, "Array length is not equal"); + for (let i = 0; i < expected.length; i++) { + assert_array_equals(actual[i], expected[i]); + } +} + +test(function () { + const headers = new Headers(headerList); + assert_equals( + headers.get("set-cookie"), + "foo=bar, fizz=buzz; domain=example.com", + ); +}, "Headers.prototype.get combines set-cookie headers in order"); + +test(function () { + const headers = new Headers(headerList); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz; domain=example.com"], + ]); +}, "Headers iterator does not combine set-cookie headers"); + +test(function () { + const headers = new Headers(setCookie2HeaderList); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers iterator does not special case set-cookie2 headers"); + +test(function () { + const headers = new Headers([...headerList, ...setCookie2HeaderList]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz; domain=example.com"], + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers iterator does not combine set-cookie & set-cookie2 headers"); + +test(function () { + // Values are in non alphabetic order, and the iterator should yield in the + // headers in the exact order of the input. + const headers = new Headers([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); +}, "Headers iterator preserves set-cookie ordering"); + +test( + function () { + const headers = new Headers([ + ["xylophone-header", "1"], + ["best-header", "2"], + ["set-cookie", "3"], + ["a-cool-header", "4"], + ["set-cookie", "5"], + ["a-cool-header", "6"], + ["best-header", "7"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["a-cool-header", "4, 6"], + ["best-header", "2, 7"], + ["set-cookie", "3"], + ["set-cookie", "5"], + ["xylophone-header", "1"], + ]); + }, + "Headers iterator preserves per header ordering, but sorts keys alphabetically", +); + +test( + function () { + const headers = new Headers([ + ["xylophone-header", "7"], + ["best-header", "6"], + ["set-cookie", "5"], + ["a-cool-header", "4"], + ["set-cookie", "3"], + ["a-cool-header", "2"], + ["best-header", "1"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["a-cool-header", "4, 2"], + ["best-header", "6, 1"], + ["set-cookie", "5"], + ["set-cookie", "3"], + ["xylophone-header", "7"], + ]); + }, + "Headers iterator preserves per header ordering, but sorts keys alphabetically (and ignores value ordering)", +); + +test(function () { + const headers = new Headers([["fizz", "buzz"], ["X-Header", "test"]]); + const iterator = headers[Symbol.iterator](); + assert_array_equals(iterator.next().value, ["fizz", "buzz"]); + headers.append("Set-Cookie", "a=b"); + assert_array_equals(iterator.next().value, ["set-cookie", "a=b"]); + headers.append("Accept", "text/html"); + assert_array_equals(iterator.next().value, ["set-cookie", "a=b"]); + assert_array_equals(iterator.next().value, ["x-header", "test"]); + headers.append("set-cookie", "c=d"); + assert_array_equals(iterator.next().value, ["x-header", "test"]); + assert_true(iterator.next().done); +}, "Headers iterator is correctly updated with set-cookie changes"); + +test(function () { + const headers = new Headers([ + ["set-cookie", "a"], + ["set-cookie", "b"], + ["set-cookie", "c"] + ]); + const iterator = headers[Symbol.iterator](); + assert_array_equals(iterator.next().value, ["set-cookie", "a"]); + headers.delete("set-cookie"); + headers.append("set-cookie", "d"); + headers.append("set-cookie", "e"); + headers.append("set-cookie", "f"); + assert_array_equals(iterator.next().value, ["set-cookie", "e"]); + assert_array_equals(iterator.next().value, ["set-cookie", "f"]); + assert_true(iterator.next().done); +}, "Headers iterator is correctly updated with set-cookie changes #2"); + +test(function () { + const headers = new Headers(headerList); + assert_true(headers.has("sEt-cOoKiE")); +}, "Headers.prototype.has works for set-cookie"); + +test(function () { + const headers = new Headers(setCookie2HeaderList); + headers.append("set-Cookie", "foo=bar"); + headers.append("sEt-cOoKiE", "fizz=buzz"); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz"], + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers.prototype.append works for set-cookie"); + +test(function () { + const headers = new Headers(headerList); + headers.set("set-cookie", "foo2=bar2"); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo2=bar2"], + ]); +}, "Headers.prototype.set works for set-cookie"); + +test(function () { + const headers = new Headers(headerList); + headers.delete("set-Cookie"); + const list = [...headers]; + assert_nested_array_equals(list, []); +}, "Headers.prototype.delete works for set-cookie"); + +test(function () { + const headers = new Headers(); + assert_array_equals(headers.getSetCookie(), []); +}, "Headers.prototype.getSetCookie with no headers present"); + +test(function () { + const headers = new Headers([headerList[0]]); + assert_array_equals(headers.getSetCookie(), ["foo=bar"]); +}, "Headers.prototype.getSetCookie with one header"); + +test(function () { + const headers = new Headers({ "Set-Cookie": "foo=bar" }); + assert_array_equals(headers.getSetCookie(), ["foo=bar"]); +}, "Headers.prototype.getSetCookie with one header created from an object"); + +test(function () { + const headers = new Headers(headerList); + assert_array_equals(headers.getSetCookie(), [ + "foo=bar", + "fizz=buzz; domain=example.com", + ]); +}, "Headers.prototype.getSetCookie with multiple headers"); + +test(function () { + const headers = new Headers([["set-cookie", ""]]); + assert_array_equals(headers.getSetCookie(), [""]); +}, "Headers.prototype.getSetCookie with an empty header"); + +test(function () { + const headers = new Headers([["set-cookie", "x"], ["set-cookie", "x"]]); + assert_array_equals(headers.getSetCookie(), ["x", "x"]); +}, "Headers.prototype.getSetCookie with two equal headers"); + +test(function () { + const headers = new Headers([ + ["set-cookie2", "x"], + ["set-cookie", "y"], + ["set-cookie2", "z"], + ]); + assert_array_equals(headers.getSetCookie(), ["y"]); +}, "Headers.prototype.getSetCookie ignores set-cookie2 headers"); + +test(function () { + // Values are in non alphabetic order, and the iterator should yield in the + // headers in the exact order of the input. + const headers = new Headers([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); + assert_array_equals(headers.getSetCookie(), ["z=z", "a=a", "n=n"]); +}, "Headers.prototype.getSetCookie preserves header ordering"); + +test(function () { + const headers = new Headers({"Set-Cookie": " a=b\n"}); + headers.append("set-cookie", "\n\rc=d "); + assert_nested_array_equals([...headers], [ + ["set-cookie", "a=b"], + ["set-cookie", "c=d"] + ]); + headers.set("set-cookie", "\te=f "); + assert_nested_array_equals([...headers], [["set-cookie", "e=f"]]); +}, "Adding Set-Cookie headers normalizes their value"); + +test(function () { + assert_throws_js(TypeError, () => { + new Headers({"set-cookie": "\0"}); + }); + + const headers = new Headers(); + assert_throws_js(TypeError, () => { + headers.append("Set-Cookie", "a\nb"); + }); + assert_throws_js(TypeError, () => { + headers.set("Set-Cookie", "a\rb"); + }); +}, "Adding invalid Set-Cookie headers throws"); + +test(function () { + const response = new Response(); + response.headers.append("Set-Cookie", "foo=bar"); + assert_array_equals(response.headers.getSetCookie(), []); + response.headers.append("sEt-cOokIe", "bar=baz"); + assert_array_equals(response.headers.getSetCookie(), []); +}, "Set-Cookie is a forbidden response header"); diff --git a/test/wpt/tests/fetch/api/headers/header-values-normalize.any.js b/test/wpt/tests/fetch/api/headers/header-values-normalize.any.js new file mode 100644 index 0000000..5710554 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/header-values-normalize.any.js @@ -0,0 +1,72 @@ +// META: title=Header value normalizing test +// META: global=window,worker +// META: timeout=long + +"use strict"; + +for(let i = 0; i < 0x21; i++) { + let fail = false, + strip = false + + // REMOVE 0x0B/0x0C exception once https://github.com/web-platform-tests/wpt/issues/8372 is fixed + if(i === 0x0B || i === 0x0C) + continue + + if(i === 0) { + fail = true + } + + if(i === 0x09 || i === 0x0A || i === 0x0D || i === 0x20) { + strip = true + } + + let url = "../resources/inspect-headers.py?headers=val1|val2|val3", + val = String.fromCharCode(i), + expectedVal = strip ? "" : val, + val1 = val, + expectedVal1 = expectedVal, + val2 = "x" + val, + expectedVal2 = "x" + expectedVal, + val3 = val + "x", + expectedVal3 = expectedVal + "x" + + // XMLHttpRequest is not available in service workers + if (!self.GLOBAL.isWorker()) { + async_test((t) => { + let xhr = new XMLHttpRequest() + xhr.open("POST", url) + if(fail) { + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val1", val1)) + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val2", val2)) + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val3", val3)) + t.done() + } else { + xhr.setRequestHeader("val1", val1) + xhr.setRequestHeader("val2", val2) + xhr.setRequestHeader("val3", val3) + xhr.onload = t.step_func_done(() => { + assert_equals(xhr.getResponseHeader("x-request-val1"), expectedVal1) + assert_equals(xhr.getResponseHeader("x-request-val2"), expectedVal2) + assert_equals(xhr.getResponseHeader("x-request-val3"), expectedVal3) + }) + xhr.send() + } + }, "XMLHttpRequest with value " + encodeURI(val)) + } + + promise_test((t) => { + if(fail) { + return Promise.all([ + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val1": val1} })), + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val2": val2} })), + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val3": val3} })) + ]) + } else { + return fetch(url, { headers: {"val1": val1, "val2": val2, "val3": val3} }).then((res) => { + assert_equals(res.headers.get("x-request-val1"), expectedVal1) + assert_equals(res.headers.get("x-request-val2"), expectedVal2) + assert_equals(res.headers.get("x-request-val3"), expectedVal3) + }) + } + }, "fetch() with value " + encodeURI(val)) +} diff --git a/test/wpt/tests/fetch/api/headers/header-values.any.js b/test/wpt/tests/fetch/api/headers/header-values.any.js new file mode 100644 index 0000000..bb7570c --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/header-values.any.js @@ -0,0 +1,63 @@ +// META: title=Header value test +// META: global=window,worker +// META: timeout=long + +"use strict"; + +// Invalid values +[0, 0x0A, 0x0D].forEach(val => { + val = "x" + String.fromCharCode(val) + "x" + + // XMLHttpRequest is not available in service workers + if (!self.GLOBAL.isWorker()) { + test(() => { + let xhr = new XMLHttpRequest() + xhr.open("POST", "/") + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("value-test", val)) + }, "XMLHttpRequest with value " + encodeURI(val) + " needs to throw") + } + + promise_test(t => promise_rejects_js(t, TypeError, fetch("/", { headers: {"value-test": val} })), "fetch() with value " + encodeURI(val) + " needs to throw") +}) + +// Valid values +let headerValues =[] +for(let i = 0; i < 0x100; i++) { + if(i === 0 || i === 0x0A || i === 0x0D) { + continue + } + headerValues.push("x" + String.fromCharCode(i) + "x") +} +var url = "../resources/inspect-headers.py?headers=" +headerValues.forEach((_, i) => { + url += "val" + i + "|" +}) + +// XMLHttpRequest is not available in service workers +if (!self.GLOBAL.isWorker()) { + async_test((t) => { + let xhr = new XMLHttpRequest() + xhr.open("POST", url) + headerValues.forEach((val, i) => { + xhr.setRequestHeader("val" + i, val) + }) + xhr.onload = t.step_func_done(() => { + headerValues.forEach((val, i) => { + assert_equals(xhr.getResponseHeader("x-request-val" + i), val) + }) + }) + xhr.send() + }, "XMLHttpRequest with all valid values") +} + +promise_test((t) => { + const headers = new Headers + headerValues.forEach((val, i) => { + headers.append("val" + i, val) + }) + return fetch(url, { headers }).then((res) => { + headerValues.forEach((val, i) => { + assert_equals(res.headers.get("x-request-val" + i), val) + }) + }) +}, "fetch() with all valid values") diff --git a/test/wpt/tests/fetch/api/headers/headers-basic.any.js b/test/wpt/tests/fetch/api/headers/headers-basic.any.js new file mode 100644 index 0000000..ead1047 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-basic.any.js @@ -0,0 +1,275 @@ +// META: title=Headers structure +// META: global=window,worker + +"use strict"; + +test(function() { + new Headers(); +}, "Create headers from no parameter"); + +test(function() { + new Headers(undefined); +}, "Create headers from undefined parameter"); + +test(function() { + new Headers({}); +}, "Create headers from empty object"); + +var parameters = [null, 1]; +parameters.forEach(function(parameter) { + test(function() { + assert_throws_js(TypeError, function() { new Headers(parameter) }); + }, "Create headers with " + parameter + " should throw"); +}); + +var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3", + "name4": null, + "name5": undefined, + "name6": 1, + "Content-Type": "value4" +}; + +var headerSeq = []; +for (var name in headerDict) + headerSeq.push([name, headerDict[name]]); + +test(function() { + var headers = new Headers(headerSeq); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } + assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary"); +}, "Create headers with sequence"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with record"); + +test(function() { + var headers = new Headers(headerDict); + var headers2 = new Headers(headers); + for (name in headerDict) { + assert_equals(headers2.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with existing headers"); + +test(function() { + var headers = new Headers() + headers[Symbol.iterator] = function *() { + yield ["test", "test"] + } + var headers2 = new Headers(headers) + assert_equals(headers2.get("test"), "test") +}, "Create headers with existing headers with custom iterator"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.append(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check append method"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.set(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check set method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_true(headers.has(name),"headers has name " + name); + + assert_false(headers.has("nameNotInHeaders"),"headers do not have header: nameNotInHeaders"); +}, "Check has method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_true(headers.has(name),"headers have a header: " + name); + headers.delete(name) + assert_true(!headers.has(name),"headers do not have anymore a header: " + name); + } +}, "Check delete method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + + assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value"); +}, "Check get method"); + +var headerEntriesDict = {"name1": "value1", + "Name2": "value2", + "name": "value3", + "content-Type": "value4", + "Content-Typ": "value5", + "Content-Types": "value6" +}; +var sortedHeaderDict = {}; +var headerValues = []; +var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) { + sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]; + headerValues.push(headerEntriesDict[value]); + return value.toLowerCase(); +}).sort(); + +var iteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())); +function checkIteratorProperties(iterator) { + var prototype = Object.getPrototypeOf(iterator); + assert_equals(Object.getPrototypeOf(prototype), iteratorPrototype); + + var descriptor = Object.getOwnPropertyDescriptor(prototype, "next"); + assert_true(descriptor.configurable, "configurable"); + assert_true(descriptor.enumerable, "enumerable"); + assert_true(descriptor.writable, "writable"); +} + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.keys(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, key); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const key of headers.keys()) + assert_true(sortedHeaderKeys.indexOf(key) != -1); +}, "Check keys method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.values(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const value of headers.values()) + assert_true(headerValues.indexOf(value) != -1); +}, "Check values method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.entries(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const entry of headers.entries()) + assert_equals(entry[1], sortedHeaderDict[entry[0]]); +}, "Check entries method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers[Symbol.iterator](); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); +}, "Check Symbol.iterator method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var reference = sortedHeaderKeys[Symbol.iterator](); + headers.forEach(function(value, key, container) { + assert_equals(headers, container); + const entry = reference.next(); + assert_false(entry.done); + assert_equals(key, entry.value); + assert_equals(value, sortedHeaderDict[entry.value]); + }); + assert_true(reference.next().done); +}, "Check forEach method"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + headers.delete("foo"); + } + assert_array_equals(actualKeys, ["bar", "baz"]); + assert_array_equals(actualValues, ["0", "1"]); +}, "Iteration skips elements removed while iterating"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.delete("bar"); + } + assert_array_equals(actualKeys, ["bar", "baz", "quux"]); + assert_array_equals(actualValues, ["0", "1", "3"]); +}, "Removing elements already iterated over causes an element to be skipped during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("X-yZ", "4"); + } + assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"]); + assert_array_equals(actualValues, ["0", "1", "2", "3", "4"]); +}, "Appending a value pair during iteration causes it to be reached during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("abc", "-1"); + } + assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"]); + assert_array_equals(actualValues, ["0", "1", "1", "2", "3"]); +}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time"); diff --git a/test/wpt/tests/fetch/api/headers/headers-casing.any.js b/test/wpt/tests/fetch/api/headers/headers-casing.any.js new file mode 100644 index 0000000..20b8a9d --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-casing.any.js @@ -0,0 +1,54 @@ +// META: title=Headers case management +// META: global=window,worker + +"use strict"; + +var headerDictCase = {"UPPERCASE": "value1", + "lowercase": "value2", + "mixedCase": "value3", + "Content-TYPE": "value4" + }; + +function checkHeadersCase(originalName, headersToCheck, expectedDict) { + var lowCaseName = originalName.toLowerCase(); + var upCaseName = originalName.toUpperCase(); + var expectedValue = expectedDict[originalName]; + assert_equals(headersToCheck.get(originalName), expectedValue, + "name: " + originalName + " has value: " + expectedValue); + assert_equals(headersToCheck.get(lowCaseName), expectedValue, + "name: " + lowCaseName + " has value: " + expectedValue); + assert_equals(headersToCheck.get(upCaseName), expectedValue, + "name: " + upCaseName + " has value: " + expectedValue); +} + +test(function() { + var headers = new Headers(headerDictCase); + for (const name in headerDictCase) + checkHeadersCase(name, headers, headerDictCase) +}, "Create headers, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) { + headers.append(name, headerDictCase[name]); + checkHeadersCase(name, headers, headerDictCase); + } +}, "Check append method, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) { + headers.set(name, headerDictCase[name]); + checkHeadersCase(name, headers, headerDictCase); + } +}, "Check set method, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) + headers.set(name, headerDictCase[name]); + for (const name in headerDictCase) + headers.delete(name.toLowerCase()); + for (const name in headerDictCase) + assert_false(headers.has(name), "header " + name + " should have been deleted"); +}, "Check delete method, names use characters with different case"); diff --git a/test/wpt/tests/fetch/api/headers/headers-combine.any.js b/test/wpt/tests/fetch/api/headers/headers-combine.any.js new file mode 100644 index 0000000..4f3b6d1 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-combine.any.js @@ -0,0 +1,66 @@ +// META: title=Headers have combined (and sorted) values +// META: global=window,worker + +"use strict"; + +var headerSeqCombine = [["single", "singleValue"], + ["double", "doubleValue1"], + ["double", "doubleValue2"], + ["triple", "tripleValue1"], + ["triple", "tripleValue2"], + ["triple", "tripleValue3"] +]; +var expectedDict = {"single": "singleValue", + "double": "doubleValue1, doubleValue2", + "triple": "tripleValue1, tripleValue2, tripleValue3" +}; + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) + assert_equals(headers.get(name), expectedDict[name]); +}, "Create headers using same name for different values"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + assert_true(headers.has(name), "name: " + name + " has value(s)"); + headers.delete(name); + assert_false(headers.has(name), "name: " + name + " has no value(s) anymore"); + } +}, "Check delete and has methods when using same name for different values"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + headers.set(name,"newSingleValue"); + assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue"); + } +}, "Check set methods when called with already used name"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + var value = headers.get(name); + headers.append(name,"newSingleValue"); + assert_equals(headers.get(name), (value + ", " + "newSingleValue")); + } +}, "Check append methods when called with already used name"); + +test(() => { + const headers = new Headers([["1", "a"],["1", "b"]]); + for(let header of headers) { + assert_array_equals(header, ["1", "a, b"]); + } +}, "Iterate combined values"); + +test(() => { + const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), + expected = [["1", "b"], ["2", "a, b"]]; + let i = 0; + for(let header of headers) { + assert_array_equals(header, expected[i]); + i++; + } + assert_equals(i, 2); +}, "Iterate combined values in sorted order") diff --git a/test/wpt/tests/fetch/api/headers/headers-errors.any.js b/test/wpt/tests/fetch/api/headers/headers-errors.any.js new file mode 100644 index 0000000..82dadd8 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-errors.any.js @@ -0,0 +1,96 @@ +// META: title=Headers errors +// META: global=window,worker + +"use strict"; + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["name"]]); }); +}, "Create headers giving an array having one string as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]); }); +}, "Create headers giving an array having three strings as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["invalidĀ", "Value1"]]); }); +}, "Create headers giving bad header name as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["name", "invalidValueĀ"]]); }); +}, "Create headers giving bad header value as init argument"); + +var badNames = ["invalidĀ", {}]; +var badValues = ["invalidĀ"]; + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.get(name); }); + }, "Check headers get with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.delete(name); }); + }, "Check headers delete with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.has(name); }); + }, "Check headers has with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.set(name, "Value1"); }); + }, "Check headers set with an invalid name " + name); +}); + +badValues.forEach(function(value) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.set("name", value); }); + }, "Check headers set with an invalid value " + value); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.append("invalidĀ", "Value1"); }); + }, "Check headers append with an invalid name " + name); +}); + +badValues.forEach(function(value) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.append("name", value); }); + }, "Check headers append with an invalid value " + value); +}); + +test(function() { + var headers = new Headers([["name", "value"]]); + assert_throws_js(TypeError, function() { headers.forEach(); }); + assert_throws_js(TypeError, function() { headers.forEach(undefined); }); + assert_throws_js(TypeError, function() { headers.forEach(1); }); +}, "Headers forEach throws if argument is not callable"); + +test(function() { + var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]]); + var counter = 0; + try { + headers.forEach(function(value, name) { + counter++; + if (name == "name2") + throw "error"; + }); + } catch (e) { + assert_equals(counter, 2); + assert_equals(e, "error"); + return; + } + assert_unreached(); +}, "Headers forEach loop should stop if callback is throwing exception"); diff --git a/test/wpt/tests/fetch/api/headers/headers-no-cors.any.js b/test/wpt/tests/fetch/api/headers/headers-no-cors.any.js new file mode 100644 index 0000000..60dbb9e --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-no-cors.any.js @@ -0,0 +1,59 @@ +// META: global=window,worker + +"use strict"; + +promise_test(() => fetch("../cors/resources/not-cors-safelisted.json").then(res => res.json().then(runTests)), "Loading data…"); + +const longValue = "s".repeat(127); + +[ + { + "headers": ["accept", "accept-language", "content-language"], + "values": [longValue, "", longValue] + }, + { + "headers": ["accept", "accept-language", "content-language"], + "values": ["", longValue] + }, + { + "headers": ["content-type"], + "values": ["text/plain;" + "s".repeat(116), "text/plain"] + } +].forEach(testItem => { + testItem.headers.forEach(header => { + test(() => { + const noCorsHeaders = new Request("about:blank", { mode: "no-cors" }).headers; + testItem.values.forEach((value) => { + noCorsHeaders.append(header, value); + assert_equals(noCorsHeaders.get(header), testItem.values[0], '1'); + }); + noCorsHeaders.set(header, testItem.values.join(", ")); + assert_equals(noCorsHeaders.get(header), testItem.values[0], '2'); + noCorsHeaders.delete(header); + assert_false(noCorsHeaders.has(header)); + }, "\"no-cors\" Headers object cannot have " + header + " set to " + testItem.values.join(", ")); + }); +}); + +function runTests(testArray) { + testArray = testArray.concat([ + ["dpr", "2"], + ["rtt", "1.0"], + ["downlink", "-1.0"], + ["ect", "6g"], + ["save-data", "on"], + ["viewport-width", "100"], + ["width", "100"], + ["unknown", "doesitmatter"] + ]); + testArray.forEach(testItem => { + const [headerName, headerValue] = testItem; + test(() => { + const noCorsHeaders = new Request("about:blank", { mode: "no-cors" }).headers; + noCorsHeaders.append(headerName, headerValue); + assert_false(noCorsHeaders.has(headerName)); + noCorsHeaders.set(headerName, headerValue); + assert_false(noCorsHeaders.has(headerName)); + }, "\"no-cors\" Headers object cannot have " + headerName + "/" + headerValue + " as header"); + }); +} diff --git a/test/wpt/tests/fetch/api/headers/headers-normalize.any.js b/test/wpt/tests/fetch/api/headers/headers-normalize.any.js new file mode 100644 index 0000000..68cf5b8 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-normalize.any.js @@ -0,0 +1,56 @@ +// META: title=Headers normalize values +// META: global=window,worker + +"use strict"; + +const expectations = { + "name1": [" space ", "space"], + "name2": ["\ttab\t", "tab"], + "name3": [" spaceAndTab\t", "spaceAndTab"], + "name4": ["\r\n newLine", "newLine"], //obs-fold cases + "name5": ["newLine\r\n ", "newLine"], + "name6": ["\r\n\tnewLine", "newLine"], + "name7": ["\t\f\tnewLine\n", "\f\tnewLine"], + "name8": ["newLine\xa0", "newLine\xa0"], // \xa0 == non breaking space +}; + +test(function () { + const headerDict = Object.fromEntries( + Object.entries(expectations).map(([name, [actual]]) => [name, actual]), + ); + var headers = new Headers(headerDict); + for (const name in expectations) { + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has normalized value: " + expected, + ); + } +}, "Create headers with not normalized values"); + +test(function () { + var headers = new Headers(); + for (const name in expectations) { + headers.append(name, expectations[name][0]); + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has value: " + expected, + ); + } +}, "Check append method with not normalized values"); + +test(function () { + var headers = new Headers(); + for (const name in expectations) { + headers.set(name, expectations[name][0]); + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has value: " + expected, + ); + } +}, "Check set method with not normalized values"); diff --git a/test/wpt/tests/fetch/api/headers/headers-record.any.js b/test/wpt/tests/fetch/api/headers/headers-record.any.js new file mode 100644 index 0000000..fa85391 --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-record.any.js @@ -0,0 +1,357 @@ +// META: global=window,worker + +"use strict"; + +var log = []; +function clearLog() { + log = []; +} +function addLogEntry(name, args) { + log.push([ name, ...args ]); +} + +var loggingHandler = { +}; + +setup(function() { + for (let prop of Object.getOwnPropertyNames(Reflect)) { + loggingHandler[prop] = function(...args) { + addLogEntry(prop, args); + return Reflect[prop](...args); + } + } +}); + +test(function() { + var h = new Headers(); + assert_equals([...h].length, 0); +}, "Passing nothing to Headers constructor"); + +test(function() { + var h = new Headers(undefined); + assert_equals([...h].length, 0); +}, "Passing undefined to Headers constructor"); + +test(function() { + assert_throws_js(TypeError, function() { + var h = new Headers(null); + }); +}, "Passing null to Headers constructor"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b" }; + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["a"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); +}, "Basic operation with one property"); + +test(function() { + this.add_cleanup(clearLog); + var recordProto = { c: "d" }; + var record = Object.create(recordProto, { a: { value: "b", enumerable: true } }); + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["a"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); +}, "Basic operation with one property and a proto"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b", c: "d" }; + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[5], ["get", record, "c", proxy]); + + // Check the results. + assert_equals([...h].length, 2); + assert_array_equals([...h.keys()], ["a", "c"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with two properties"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b", "\uFFFF": "d" }; + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, function() { + var h = new Headers(proxy); + }); + + assert_equals(log.length, 5); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "\uFFFF"]); + // The second [[Get]] never happens, because we convert the invalid name to a + // ByteString first and throw. +}, "Correct operation ordering with two properties one of which has an invalid name"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "\uFFFF", c: "d" } + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, function() { + var h = new Headers(proxy); + }); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Nothing else after this, because converting the result of that [[Get]] to a + // ByteString throws. +}, "Correct operation ordering with two properties one of which has an invalid value"); + +test(function() { + this.add_cleanup(clearLog); + var record = {}; + Object.defineProperty(record, "a", { value: "b", enumerable: false }); + Object.defineProperty(record, "c", { value: "d", enumerable: true }); + Object.defineProperty(record, "e", { value: "f", enumerable: false }); + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // No [[Get]] because not enumerable + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[3], ["getOwnPropertyDescriptor", record, "c"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[4], ["get", record, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", record, "e"]); + // No [[Get]] because not enumerable + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["c"]); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with non-enumerable properties"); + +test(function() { + this.add_cleanup(clearLog); + var record = {a: "b", c: "d", e: "f"}; + var lyingHandler = { + getOwnPropertyDescriptor: function(target, name) { + if (name == "a" || name == "e") { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(target, name); + } + }; + var lyingProxy = new Proxy(record, lyingHandler); + var proxy = new Proxy(lyingProxy, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", lyingProxy, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", lyingProxy]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", lyingProxy, "a"]); + // No [[Get]] because no descriptor + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[3], ["getOwnPropertyDescriptor", lyingProxy, "c"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[4], ["get", lyingProxy, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", lyingProxy, "e"]); + // No [[Get]] because no descriptor + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["c"]); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with undefined descriptors"); + +test(function() { + this.add_cleanup(clearLog); + var record = {a: "b", c: "d"}; + var lyingHandler = { + ownKeys: function() { + return [ "a", "c", "a", "c" ]; + }, + }; + var lyingProxy = new Proxy(record, lyingHandler); + var proxy = new Proxy(lyingProxy, loggingHandler); + + // Returning duplicate keys from ownKeys() throws a TypeError. + assert_throws_js(TypeError, + function() { var h = new Headers(proxy); }); + + assert_equals(log.length, 2); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", lyingProxy, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", lyingProxy]); +}, "Correct operation ordering with repeated keys"); + +test(function() { + this.add_cleanup(clearLog); + var record = { + a: "b", + [Symbol.toStringTag]: { + // Make sure the ToString conversion of the value happens + // after the ToString conversion of the key. + toString: function () { addLogEntry("toString", [this]); return "nope"; } + }, + c: "d" }; + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, + function() { var h = new Headers(proxy); }); + + assert_equals(log.length, 7); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[5], ["get", record, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[6], ["getOwnPropertyDescriptor", record, + Symbol.toStringTag]); + // Then we throw an exception converting the Symbol to a string, before we do + // the third [[Get]]. +}, "Basic operation with Symbol keys"); + +test(function() { + this.add_cleanup(clearLog); + var record = { + a: { + toString: function() { addLogEntry("toString", [this]); return "b"; } + }, + [Symbol.toStringTag]: { + toString: function () { addLogEntry("toString", [this]); return "nope"; } + }, + c: { + toString: function() { addLogEntry("toString", [this]); return "d"; } + } + }; + // Now make that Symbol-named property not enumerable. + Object.defineProperty(record, Symbol.toStringTag, { enumerable: false }); + assert_array_equals(Reflect.ownKeys(record), + ["a", "c", Symbol.toStringTag]); + + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 9); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the ToString on the value. + assert_array_equals(log[4], ["toString", record.a]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[6], ["get", record, "c", proxy]); + // Then the ToString on the value. + assert_array_equals(log[7], ["toString", record.c]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[8], ["getOwnPropertyDescriptor", record, + Symbol.toStringTag]); + // No [[Get]] because not enumerable. + + // Check the results. + assert_equals([...h].length, 2); + assert_array_equals([...h.keys()], ["a", "c"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Operation with non-enumerable Symbol keys"); diff --git a/test/wpt/tests/fetch/api/headers/headers-structure.any.js b/test/wpt/tests/fetch/api/headers/headers-structure.any.js new file mode 100644 index 0000000..d826bca --- /dev/null +++ b/test/wpt/tests/fetch/api/headers/headers-structure.any.js @@ -0,0 +1,20 @@ +// META: title=Headers basic +// META: global=window,worker + +"use strict"; + +var headers = new Headers(); +var methods = ["append", + "delete", + "get", + "has", + "set", + //Headers is iterable + "entries", + "keys", + "values" + ]; +for (var idx in methods) + test(function() { + assert_true(methods[idx] in headers, "headers has " + methods[idx] + " method"); + }, "Headers has " + methods[idx] + " method"); diff --git a/test/wpt/tests/fetch/api/idlharness.any.js b/test/wpt/tests/fetch/api/idlharness.any.js new file mode 100644 index 0000000..7b3c694 --- /dev/null +++ b/test/wpt/tests/fetch/api/idlharness.any.js @@ -0,0 +1,21 @@ +// META: global=window,worker +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: timeout=long + +idl_test( + ['fetch'], + ['referrer-policy', 'html', 'dom'], + idl_array => { + idl_array.add_objects({ + Headers: ["new Headers()"], + Request: ["new Request('about:blank')"], + Response: ["new Response()"], + }); + if (self.GLOBAL.isWindow()) { + idl_array.add_objects({ Window: ['window'] }); + } else if (self.GLOBAL.isWorker()) { + idl_array.add_objects({ WorkerGlobalScope: ['self'] }); + } + } +); diff --git a/test/wpt/tests/fetch/api/policies/csp-blocked-worker.html b/test/wpt/tests/fetch/api/policies/csp-blocked-worker.html new file mode 100644 index 0000000..e8660df --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/csp-blocked-worker.html @@ -0,0 +1,16 @@ + + + + + Fetch in worker: blocked by CSP + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/csp-blocked.html b/test/wpt/tests/fetch/api/policies/csp-blocked.html new file mode 100644 index 0000000..99e90df --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/csp-blocked.html @@ -0,0 +1,15 @@ + + + + + Fetch: blocked by CSP + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/csp-blocked.html.headers b/test/wpt/tests/fetch/api/policies/csp-blocked.html.headers new file mode 100644 index 0000000..c8c1e9f --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/csp-blocked.html.headers @@ -0,0 +1 @@ +Content-Security-Policy: connect-src 'none'; \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/csp-blocked.js b/test/wpt/tests/fetch/api/policies/csp-blocked.js new file mode 100644 index 0000000..28653ff --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/csp-blocked.js @@ -0,0 +1,13 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); +} + +//Content-Security-Policy: connect-src 'none'; cf .headers file +cspViolationUrl = RESOURCES_DIR + "top.txt"; + +promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(cspViolationUrl)); +}, "Fetch is blocked by CSP, got a TypeError"); + +done(); diff --git a/test/wpt/tests/fetch/api/policies/csp-blocked.js.headers b/test/wpt/tests/fetch/api/policies/csp-blocked.js.headers new file mode 100644 index 0000000..c8c1e9f --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/csp-blocked.js.headers @@ -0,0 +1 @@ +Content-Security-Policy: connect-src 'none'; \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/nested-policy.js b/test/wpt/tests/fetch/api/policies/nested-policy.js new file mode 100644 index 0000000..b0d1769 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/nested-policy.js @@ -0,0 +1 @@ +// empty, but referrer-policy set on this file diff --git a/test/wpt/tests/fetch/api/policies/nested-policy.js.headers b/test/wpt/tests/fetch/api/policies/nested-policy.js.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/nested-policy.js.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer-service-worker.https.html b/test/wpt/tests/fetch/api/policies/referrer-no-referrer-service-worker.https.html new file mode 100644 index 0000000..af898aa --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in service worker: referrer with no-referrer policy + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer-worker.html b/test/wpt/tests/fetch/api/policies/referrer-no-referrer-worker.html new file mode 100644 index 0000000..dbef9bb --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with no-referrer policy + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html new file mode 100644 index 0000000..22a6f34 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html @@ -0,0 +1,15 @@ + + + + + Fetch: referrer with no-referrer policy + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html.headers b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.html.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js new file mode 100644 index 0000000..60600bf --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js @@ -0,0 +1,19 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); +} + +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=origin"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + var referrer = resp.headers.get("x-request-referer"); + //Either no referrer header is sent or it is empty + if (referrer) + assert_equals(referrer, "", "request's referrer is empty"); + }); +}, "Request's referrer is empty"); + +done(); diff --git a/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js.headers b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-no-referrer.js.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-service-worker.https.html b/test/wpt/tests/fetch/api/policies/referrer-origin-service-worker.https.html new file mode 100644 index 0000000..4018b83 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in service worker: referrer with no-referrer policy + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html new file mode 100644 index 0000000..d87192e --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html @@ -0,0 +1,17 @@ + + + + + Fetch in service worker: referrer with origin-when-cross-origin policy + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-worker.html b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-worker.html new file mode 100644 index 0000000..f95ae8c --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin-worker.html @@ -0,0 +1,16 @@ + + + + + Fetch in worker: referrer with origin-when-cross-origin policy + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html new file mode 100644 index 0000000..5cd79e4 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with origin-when-cross-origin policy + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html.headers b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html.headers new file mode 100644 index 0000000..ad768e6 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.html.headers @@ -0,0 +1 @@ +Referrer-Policy: origin-when-cross-origin diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js new file mode 100644 index 0000000..0adadbc --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js @@ -0,0 +1,21 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + importScripts("/common/get-host-info.sub.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerOrigin = location.origin + '/'; +var fetchedUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Request's referrer is origin"); + +done(); diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js.headers b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js.headers new file mode 100644 index 0000000..ad768e6 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-when-cross-origin.js.headers @@ -0,0 +1 @@ +Referrer-Policy: origin-when-cross-origin diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin-worker.html b/test/wpt/tests/fetch/api/policies/referrer-origin-worker.html new file mode 100644 index 0000000..bb80dd5 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with origin policy + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin.html b/test/wpt/tests/fetch/api/policies/referrer-origin.html new file mode 100644 index 0000000..b164afe --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with origin policy + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin.html.headers b/test/wpt/tests/fetch/api/policies/referrer-origin.html.headers new file mode 100644 index 0000000..5b29739 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin.html.headers @@ -0,0 +1 @@ +Referrer-Policy: origin diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin.js b/test/wpt/tests/fetch/api/policies/referrer-origin.js new file mode 100644 index 0000000..918f8f2 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin.js @@ -0,0 +1,30 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerOrigin = (new URL("/", location.href)).href; +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Request's referrer is origin"); + +promise_test(function(test) { + var referrerUrl = "https://{{domains[www]}}:{{ports[https][0]}}/"; + return fetch(fetchedUrl, { "referrer": referrerUrl }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Cross-origin referrer is overridden by client origin"); + +done(); diff --git a/test/wpt/tests/fetch/api/policies/referrer-origin.js.headers b/test/wpt/tests/fetch/api/policies/referrer-origin.js.headers new file mode 100644 index 0000000..5b29739 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-origin.js.headers @@ -0,0 +1 @@ +Referrer-Policy: origin diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-service-worker.https.html b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-service-worker.https.html new file mode 100644 index 0000000..634877e --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in worker: referrer with unsafe-url policy + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-worker.html b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-worker.html new file mode 100644 index 0000000..4204577 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with unsafe-url policy + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html new file mode 100644 index 0000000..10dd79e --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with unsafe-url policy + + + + + + + + + + + \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html.headers b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html.headers new file mode 100644 index 0000000..8e23770 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.html.headers @@ -0,0 +1 @@ +Referrer-Policy: unsafe-url diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js new file mode 100644 index 0000000..4d61172 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js @@ -0,0 +1,21 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerUrl = location.href; +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerUrl, "request's referrer is " + referrerUrl); + }); +}, "Request's referrer is the full url of current document/worker"); + +done(); diff --git a/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js.headers b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js.headers new file mode 100644 index 0000000..8e23770 --- /dev/null +++ b/test/wpt/tests/fetch/api/policies/referrer-unsafe-url.js.headers @@ -0,0 +1 @@ +Referrer-Policy: unsafe-url diff --git a/test/wpt/tests/fetch/api/redirect/redirect-back-to-original-origin.any.js b/test/wpt/tests/fetch/api/redirect/redirect-back-to-original-origin.any.js new file mode 100644 index 0000000..74d731f --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-back-to-original-origin.any.js @@ -0,0 +1,38 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const BASE = location.href; +const IS_HTTPS = new URL(BASE).protocol === 'https:'; +const REMOTE_HOST = get_host_info()['REMOTE_HOST']; +const REMOTE_PORT = + IS_HTTPS ? get_host_info()['HTTPS_PORT'] : get_host_info()['HTTP_PORT']; + +const REMOTE_ORIGIN = + new URL(`//${REMOTE_HOST}:${REMOTE_PORT}`, BASE).origin; +const DESTINATION = new URL('../resources/cors-top.txt', BASE); + +function CreateURL(url, BASE, params) { + const u = new URL(url, BASE); + for (const {name, value} of params) { + u.searchParams.append(name, value); + } + return u; +} + +const redirect = + CreateURL('/fetch/api/resources/redirect.py', REMOTE_ORIGIN, + [{name: 'redirect_status', value: 303}, + {name: 'location', value: DESTINATION.href}]); + +promise_test(async (test) => { + const res = await fetch(redirect.href, {mode: 'no-cors'}); + // This is discussed at https://github.com/whatwg/fetch/issues/737. + assert_equals(res.type, 'opaque'); +}, 'original => remote => original with mode: "no-cors"'); + +promise_test(async (test) => { + const res = await fetch(redirect.href, {mode: 'cors'}); + assert_equals(res.type, 'cors'); +}, 'original => remote => original with mode: "cors"'); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-count.any.js b/test/wpt/tests/fetch/api/redirect/redirect-count.any.js new file mode 100644 index 0000000..420f9c0 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-count.any.js @@ -0,0 +1,51 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: timeout=long + +/** + * Fetches a target that returns response with HTTP status code `statusCode` to + * redirect `maxCount` times. + */ +function redirectCountTest(maxCount, {statusCode, shouldPass = true} = {}) { + const desc = `Redirect ${statusCode} ${maxCount} times`; + + const fromUrl = `${RESOURCES_DIR}redirect.py`; + const toUrl = fromUrl; + const token1 = token(); + const url = `${fromUrl}?token=${token1}` + + `&max_age=0` + + `&redirect_status=${statusCode}` + + `&max_count=${maxCount}` + + `&location=${encodeURIComponent(toUrl)}`; + + const requestInit = {'redirect': 'follow'}; + + promise_test((test) => { + return fetch(`${RESOURCES_DIR}clean-stash.py?token=${token1}`) + .then((resp) => { + assert_equals( + resp.status, 200, 'Clean stash response\'s status is 200'); + + if (!shouldPass) + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + + return fetch(url, requestInit) + .then((resp) => { + assert_equals(resp.status, 200, 'Response\'s status is 200'); + return resp.text(); + }) + .then((body) => { + assert_equals( + body, maxCount.toString(), `Redirected ${maxCount} times`); + }); + }); + }, desc); +} + +for (const statusCode of [301, 302, 303, 307, 308]) { + redirectCountTest(20, {statusCode}); + redirectCountTest(21, {statusCode, shouldPass: false}); +} + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-empty-location.any.js b/test/wpt/tests/fetch/api/redirect/redirect-empty-location.any.js new file mode 100644 index 0000000..487f4d4 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-empty-location.any.js @@ -0,0 +1,21 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// Tests receiving a redirect response with a Location header with an empty +// value. + +const url = RESOURCES_DIR + 'redirect-empty-location.py'; + +promise_test(t => { + return promise_rejects_js(t, TypeError, fetch(url, {redirect:'follow'})); +}, 'redirect response with empty Location, follow mode'); + +promise_test(t => { + return fetch(url, {redirect:'manual'}) + .then(resp => { + assert_equals(resp.type, 'opaqueredirect'); + assert_equals(resp.status, 0); + }); +}, 'redirect response with empty Location, manual mode'); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-keepalive.any.js b/test/wpt/tests/fetch/api/redirect/redirect-keepalive.any.js new file mode 100644 index 0000000..bcfc444 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-keepalive.any.js @@ -0,0 +1,94 @@ +// META: global=window +// META: title=Fetch API: keepalive handling +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +} = get_host_info(); + +/** + * In an iframe, test to fetch a keepalive URL that involves in redirect to + * another URL. + */ +function keepaliveRedirectTest( + desc, {origin1 = '', origin2 = '', withPreflight = false} = {}) { + desc = `[keepalive] ${desc}`; + promise_test(async (test) => { + const tokenToStash = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveAndRedirectIframeUrl( + tokenToStash, origin1, origin2, withPreflight); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + assert_equals(await getTokenFromMessage(), tokenToStash); + iframe.remove(); + + assertStashedTokenAsync(desc, tokenToStash); + }, `${desc}; setting up`); +} + +/** + * Opens a different site window, and in `unload` event handler, test to fetch + * a keepalive URL that involves in redirect to another URL. + */ +function keepaliveRedirectInUnloadTest(desc, { + origin1 = '', + origin2 = '', + url2 = '', + withPreflight = false, + shouldPass = true +} = {}) { + desc = `[keepalive][new window][unload] ${desc}`; + + promise_test(async (test) => { + const targetUrl = + `${HTTP_NOTSAMESITE_ORIGIN}/fetch/api/resources/keepalive-redirect-window.html?` + + `origin1=${origin1}&` + + `origin2=${origin2}&` + + `url2=${url2}&` + (withPreflight ? `with-headers` : ``); + const w = window.open(targetUrl); + const token = await getTokenFromMessage(); + w.close(); + + assertStashedTokenAsync(desc, token, {shouldPass}); + }, `${desc}; setting up`); +} + +keepaliveRedirectTest(`same-origin redirect`); +keepaliveRedirectTest( + `same-origin redirect + preflight`, {withPreflight: true}); +keepaliveRedirectTest(`cross-origin redirect`, { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +}); +keepaliveRedirectTest(`cross-origin redirect + preflight`, { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + withPreflight: true +}); + +keepaliveRedirectInUnloadTest('same-origin redirect'); +keepaliveRedirectInUnloadTest( + 'same-origin redirect + preflight', {withPreflight: true}); +keepaliveRedirectInUnloadTest('cross-origin redirect', { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +}); +keepaliveRedirectInUnloadTest('cross-origin redirect + preflight', { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + withPreflight: true +}); +keepaliveRedirectInUnloadTest( + 'redirect to file URL', {url2: 'file://tmp/bar.txt', shouldPass: false}); +keepaliveRedirectInUnloadTest( + 'redirect to data URL', + {url2: 'data:text/plain;base64,cmVzcG9uc2UncyBib2R5', shouldPass: false}); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-location-escape.tentative.any.js b/test/wpt/tests/fetch/api/redirect/redirect-location-escape.tentative.any.js new file mode 100644 index 0000000..779ad70 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-location-escape.tentative.any.js @@ -0,0 +1,46 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// See https://github.com/whatwg/fetch/issues/883 for the behavior covered by +// this test. As of writing, the Fetch spec has not been updated to cover these. + +// redirectLocation tests that a Location header of |locationHeader| is resolved +// to a URL which ends in |expectedUrlSuffix|. |locationHeader| is interpreted +// as a byte sequence via isomorphic encode, as described in [INFRA]. This +// allows the caller to specify byte sequences which are not valid UTF-8. +// However, this means, e.g., U+2603 must be passed in as "\xe2\x98\x83", its +// UTF-8 encoding, not "\u2603". +// +// [INFRA] https://infra.spec.whatwg.org/#isomorphic-encode +function redirectLocation( + desc, redirectUrl, locationHeader, expectedUrlSuffix) { + promise_test(function(test) { + // Note we use escape() instead of encodeURIComponent(), so that characters + // are escaped as bytes in the isomorphic encoding. + var url = redirectUrl + '?simple=1&location=' + escape(locationHeader); + + return fetch(url, {'redirect': 'follow'}).then(function(resp) { + assert_true( + resp.url.endsWith(expectedUrlSuffix), + resp.url + ' ends with ' + expectedUrlSuffix); + }); + }, desc); +} + +var redirUrl = RESOURCES_DIR + 'redirect.py'; +redirectLocation( + 'Redirect to escaped UTF-8', redirUrl, 'top.txt?%E2%98%83%e2%98%83', + 'top.txt?%E2%98%83%e2%98%83'); +redirectLocation( + 'Redirect to unescaped UTF-8', redirUrl, 'top.txt?\xe2\x98\x83', + 'top.txt?%E2%98%83'); +redirectLocation( + 'Redirect to escaped and unescaped UTF-8', redirUrl, + 'top.txt?\xe2\x98\x83%e2%98%83', 'top.txt?%E2%98%83%e2%98%83'); +redirectLocation( + 'Escaping produces double-percent', redirUrl, 'top.txt?%\xe2\x98\x83', + 'top.txt?%%E2%98%83'); +redirectLocation( + 'Redirect to invalid UTF-8', redirUrl, 'top.txt?\xff', 'top.txt?%FF'); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-location.any.js b/test/wpt/tests/fetch/api/redirect/redirect-location.any.js new file mode 100644 index 0000000..3d483bd --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-location.any.js @@ -0,0 +1,73 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +const VALID_URL = 'top.txt'; +const INVALID_URL = 'invalidurl:'; +const DATA_URL = 'data:text/plain;base64,cmVzcG9uc2UncyBib2R5'; + +/** + * A test to fetch a URL that returns response redirecting to `toUrl` with + * `status` as its HTTP status code. `expectStatus` can be set to test the + * status code in fetch's Promise response. + */ +function redirectLocationTest(toUrlDesc, { + toUrl = undefined, + status, + expectStatus = undefined, + mode, + shouldPass = true +} = {}) { + toUrlDesc = toUrl ? `with ${toUrlDesc}` : `without`; + const desc = `Redirect ${status} in "${mode}" mode ${toUrlDesc} location`; + const url = `${RESOURCES_DIR}redirect.py?redirect_status=${status}` + + (toUrl ? `&location=${encodeURIComponent(toUrl)}` : ''); + const requestInit = {'redirect': mode}; + if (!expectStatus) + expectStatus = status; + + promise_test((test) => { + if (mode === 'error' || !shouldPass) + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + if (mode === 'manual') + return fetch(url, requestInit).then((resp) => { + assert_equals(resp.status, 0, "Response's status is 0"); + assert_equals(resp.type, "opaqueredirect", "Response's type is opaqueredirect"); + assert_equals(resp.statusText, '', `Response's statusText is ""`); + assert_true(resp.headers.entries().next().done, "Headers should be empty"); + }); + + if (mode === 'follow') + return fetch(url, requestInit).then((resp) => { + assert_equals( + resp.status, expectStatus, `Response's status is ${expectStatus}`); + }); + assert_unreached(`${mode} is not a valid redirect mode`); + }, desc); +} + +// FIXME: We may want to mix redirect-mode and cors-mode. +for (const status of [301, 302, 303, 307, 308]) { + redirectLocationTest('without location', {status, mode: 'follow'}); + redirectLocationTest('without location', {status, mode: 'manual'}); + // FIXME: Add tests for "error" redirect-mode without location. + + // When succeeded, `follow` mode should have followed all redirects. + redirectLocationTest( + 'valid', {toUrl: VALID_URL, status, expectStatus: 200, mode: 'follow'}); + redirectLocationTest('valid', {toUrl: VALID_URL, status, mode: 'manual'}); + redirectLocationTest('valid', {toUrl: VALID_URL, status, mode: 'error'}); + + redirectLocationTest( + 'invalid', + {toUrl: INVALID_URL, status, mode: 'follow', shouldPass: false}); + redirectLocationTest('invalid', {toUrl: INVALID_URL, status, mode: 'manual'}); + redirectLocationTest('invalid', {toUrl: INVALID_URL, status, mode: 'error'}); + + redirectLocationTest( + 'data', {toUrl: DATA_URL, status, mode: 'follow', shouldPass: false}); + // FIXME: Should this pass? + redirectLocationTest('data', {toUrl: DATA_URL, status, mode: 'manual'}); + redirectLocationTest('data', {toUrl: DATA_URL, status, mode: 'error'}); +} + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-method.any.js b/test/wpt/tests/fetch/api/redirect/redirect-method.any.js new file mode 100644 index 0000000..9fe086a --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-method.any.js @@ -0,0 +1,112 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// Creates a promise_test that fetches a URL that returns a redirect response. +// +// |opts| has additional options: +// |opts.body|: the request body as a string or blob (default is empty body) +// |opts.expectedBodyAsString|: the expected response body as a string. The +// server is expected to echo the request body. The default is the empty string +// if the request after redirection isn't POST; otherwise it's |opts.body|. +// |opts.expectedRequestContentType|: the expected Content-Type of redirected +// request. +function redirectMethod(desc, redirectUrl, redirectLocation, redirectStatus, method, expectedMethod, opts) { + let url = redirectUrl; + let urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + let requestHeaders = { + "Content-Encoding": "Identity", + "Content-Language": "en-US", + "Content-Location": "foo", + }; + let requestInit = {"method": method, "redirect": "follow", "headers" : requestHeaders}; + opts = opts || {}; + if (opts.body) { + requestInit.body = opts.body; + } + + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + let expectedRequestContentType = "NO"; + if (opts.expectedRequestContentType) { + expectedRequestContentType = opts.expectedRequestContentType; + } + + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.type, "basic", "Response's type basic"); + assert_equals( + resp.headers.get("x-request-method"), + expectedMethod, + "Request method after redirection is " + expectedMethod); + let hasRequestBodyHeader = true; + if (opts.expectedStripRequestBodyHeader) { + hasRequestBodyHeader = !opts.expectedStripRequestBodyHeader; + } + assert_equals( + resp.headers.get("x-request-content-type"), + expectedRequestContentType, + "Request Content-Type after redirection is " + expectedRequestContentType); + [ + "Content-Encoding", + "Content-Language", + "Content-Location" + ].forEach(header => { + let xHeader = "x-request-" + header.toLowerCase(); + let expectedValue = hasRequestBodyHeader ? requestHeaders[header] : "NO"; + assert_equals( + resp.headers.get(xHeader), + expectedValue, + "Request " + header + " after redirection is " + expectedValue); + }); + assert_true(resp.redirected); + return resp.text().then(function(text) { + let expectedBody = ""; + if (expectedMethod == "POST") { + expectedBody = opts.expectedBodyAsString || requestInit.body; + } + let expectedContentLength = expectedBody ? expectedBody.length.toString() : "NO"; + assert_equals(text, expectedBody, "request body"); + assert_equals( + resp.headers.get("x-request-content-length"), + expectedContentLength, + "Request Content-Length after redirection is " + expectedContentLength); + }); + }); + }, desc); +} + +promise_test(function(test) { + assert_false(new Response().redirected); + return fetch(RESOURCES_DIR + "method.py").then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_false(resp.redirected); + }); +}, "Response.redirected should be false on not-redirected responses"); + +var redirUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = "method.py"; + +const stringBody = "this is my body"; +const blobBody = new Blob(["it's me the blob!", " ", "and more blob!"]); +const blobBodyAsString = "it's me the blob! and more blob!"; + +redirectMethod("Redirect 301 with GET", redirUrl, locationUrl, 301, "GET", "GET"); +redirectMethod("Redirect 301 with POST", redirUrl, locationUrl, 301, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 301 with HEAD", redirUrl, locationUrl, 301, "HEAD", "HEAD"); + +redirectMethod("Redirect 302 with GET", redirUrl, locationUrl, 302, "GET", "GET"); +redirectMethod("Redirect 302 with POST", redirUrl, locationUrl, 302, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 302 with HEAD", redirUrl, locationUrl, 302, "HEAD", "HEAD"); + +redirectMethod("Redirect 303 with GET", redirUrl, locationUrl, 303, "GET", "GET"); +redirectMethod("Redirect 303 with POST", redirUrl, locationUrl, 303, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 303 with HEAD", redirUrl, locationUrl, 303, "HEAD", "HEAD"); +redirectMethod("Redirect 303 with TESTING", redirUrl, locationUrl, 303, "TESTING", "GET", { expectedStripRequestBodyHeader: true }); + +redirectMethod("Redirect 307 with GET", redirUrl, locationUrl, 307, "GET", "GET"); +redirectMethod("Redirect 307 with POST (string body)", redirUrl, locationUrl, 307, "POST", "POST", { body: stringBody , expectedRequestContentType: "text/plain;charset=UTF-8"}); +redirectMethod("Redirect 307 with POST (blob body)", redirUrl, locationUrl, 307, "POST", "POST", { body: blobBody, expectedBodyAsString: blobBodyAsString }); +redirectMethod("Redirect 307 with HEAD", redirUrl, locationUrl, 307, "HEAD", "HEAD"); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-mode.any.js b/test/wpt/tests/fetch/api/redirect/redirect-mode.any.js new file mode 100644 index 0000000..9f1ff98 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-mode.any.js @@ -0,0 +1,59 @@ +// META: script=/common/get-host-info.sub.js + +var redirectLocation = "cors-top.txt"; +const { ORIGIN, REMOTE_ORIGIN } = get_host_info(); + +function testRedirect(origin, redirectStatus, redirectMode, corsMode) { + var url = new URL("../resources/redirect.py", self.location); + if (origin === "cross-origin") { + url.host = get_host_info().REMOTE_HOST; + url.port = get_host_info().HTTP_PORT; + } + + var urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {redirect: redirectMode, mode: corsMode}; + + promise_test(function(test) { + if (redirectMode === "error" || + (corsMode === "no-cors" && redirectMode !== "follow" && origin !== "same-origin")) + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + if (redirectMode === "manual") + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 0, "Response's status is 0"); + assert_equals(resp.type, "opaqueredirect", "Response's type is opaqueredirect"); + assert_equals(resp.statusText, "", "Response's statusText is \"\""); + assert_equals(resp.url, url + urlParameters, "Response URL should be the original one"); + }); + if (redirectMode === "follow") + return fetch(url + urlParameters, requestInit).then(function(resp) { + if (corsMode !== "no-cors" || origin === "same-origin") { + assert_true(new URL(resp.url).pathname.endsWith(redirectLocation), "Response's url should be the redirected one"); + assert_equals(resp.status, 200, "Response's status is 200"); + } else { + assert_equals(resp.type, "opaque", "Response is opaque"); + } + }); + assert_unreached(redirectMode + " is no a valid redirect mode"); + }, origin + " redirect " + redirectStatus + " in " + redirectMode + " redirect and " + corsMode + " mode"); +} + +for (var origin of ["same-origin", "cross-origin"]) { + for (var statusCode of [301, 302, 303, 307, 308]) { + for (var redirect of ["error", "manual", "follow"]) { + for (var mode of ["cors", "no-cors"]) + testRedirect(origin, statusCode, redirect, mode); + } + } +} + +promise_test(async (t) => { + const destination = `${ORIGIN}/common/blank.html`; + // We use /common/redirect.py intentionally, as we want a CORS error. + const url = + `${REMOTE_ORIGIN}/common/redirect.py?location=${destination}`; + await promise_rejects_js(t, TypeError, fetch(url, { redirect: "manual" })); +}, "manual redirect with a CORS error should be rejected"); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-origin.any.js b/test/wpt/tests/fetch/api/redirect/redirect-origin.any.js new file mode 100644 index 0000000..6001c50 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-origin.any.js @@ -0,0 +1,68 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const { + HTTP_ORIGIN, + HTTP_REMOTE_ORIGIN, +} = get_host_info(); + +/** + * Fetches `fromUrl` with 'cors' and 'follow' modes that returns response to + * redirect to `toUrl`. + */ +function testOriginAfterRedirection( + desc, method, fromUrl, toUrl, statusCode, expectedOrigin) { + desc = `[${method}] Redirect ${statusCode} ${desc}`; + const token1 = token(); + const url = `${fromUrl}?token=${token1}&max_age=0` + + `&redirect_status=${statusCode}` + + `&location=${encodeURIComponent(toUrl)}`; + + const requestInit = {method, 'mode': 'cors', 'redirect': 'follow'}; + + promise_test(function(test) { + return fetch(`${RESOURCES_DIR}clean-stash.py?token=${token1}`) + .then((cleanResponse) => { + assert_equals( + cleanResponse.status, 200, + `Clean stash response's status is 200`); + return fetch(url, requestInit).then((redirectResponse) => { + assert_equals( + redirectResponse.status, 200, + `Inspect header response's status is 200`); + assert_equals( + redirectResponse.headers.get('x-request-origin'), + expectedOrigin, 'Check origin header'); + }); + }); + }, desc); +} + +const FROM_URL = `${RESOURCES_DIR}redirect.py`; +const CORS_FROM_URL = + `${HTTP_REMOTE_ORIGIN}${dirname(location.pathname)}${FROM_URL}`; +const TO_URL = `${HTTP_ORIGIN}${dirname(location.pathname)}${ + RESOURCES_DIR}inspect-headers.py?headers=origin`; +const CORS_TO_URL = `${HTTP_REMOTE_ORIGIN}${dirname(location.pathname)}${ + RESOURCES_DIR}inspect-headers.py?cors&headers=origin`; + +for (const statusCode of [301, 302, 303, 307, 308]) { + for (const method of ['GET', 'POST']) { + testOriginAfterRedirection( + 'Same origin to same origin', method, FROM_URL, TO_URL, statusCode, + null); + testOriginAfterRedirection( + 'Same origin to other origin', method, FROM_URL, CORS_TO_URL, + statusCode, HTTP_ORIGIN); + testOriginAfterRedirection( + 'Other origin to other origin', method, CORS_FROM_URL, CORS_TO_URL, + statusCode, HTTP_ORIGIN); + // TODO(crbug.com/1432059): Fix broken tests. + testOriginAfterRedirection( + 'Other origin to same origin', method, CORS_FROM_URL, `${TO_URL}&cors`, + statusCode, 'null'); + } +} + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-referrer-override.any.js b/test/wpt/tests/fetch/api/redirect/redirect-referrer-override.any.js new file mode 100644 index 0000000..56e55d7 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-referrer-override.any.js @@ -0,0 +1,104 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function getExpectation(expectations, init, initScenario, redirectPolicy, redirectScenario) { + let policies = [ + expectations[initPolicy][initScenario], + expectations[redirectPolicy][redirectScenario] + ]; + + if (policies.includes("omitted")) { + return null; + } else if (policies.includes("origin")) { + return referrerOrigin; + } else { + // "stripped-referrer" + return referrerUrl; + } +} + +function testReferrerAfterRedirection(desc, redirectUrl, redirectLocation, referrerPolicy, redirectReferrerPolicy, expectedReferrer) { + var url = redirectUrl; + var urlParameters = "?location=" + encodeURIComponent(redirectLocation); + var description = desc + ", " + referrerPolicy + " init, " + redirectReferrerPolicy + " redirect header "; + + if (redirectReferrerPolicy) + urlParameters += "&redirect_referrerpolicy=" + redirectReferrerPolicy; + + var requestInit = {"redirect": "follow", "referrerPolicy": referrerPolicy}; + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(response) { + assert_equals(response.status, 200, "Inspect header response's status is 200"); + assert_equals(response.headers.get("x-request-referer"), expectedReferrer ? expectedReferrer : null, "Check referrer header"); + }); + }, description); +} + +var referrerOrigin = get_host_info().HTTP_ORIGIN + "/"; +var referrerUrl = location.href; + +var redirectUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?headers=referer"; +var crossLocationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +var expectations = { + "no-referrer": { + "same-origin": "omitted", + "cross-origin": "omitted" + }, + "no-referrer-when-downgrade": { + "same-origin": "stripped-referrer", + "cross-origin": "stripped-referrer" + }, + "origin": { + "same-origin": "origin", + "cross-origin": "origin" + }, + "origin-when-cross-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "origin", + }, + "same-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "omitted" + }, + "strict-origin": { + "same-origin": "origin", + "cross-origin": "origin" + }, + "strict-origin-when-cross-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "origin" + }, + "unsafe-url": { + "same-origin": "stripped-referrer", + "cross-origin": "stripped-referrer" + } +}; + +for (var initPolicy in expectations) { + for (var redirectPolicy in expectations) { + + // Redirect to same-origin URL + testReferrerAfterRedirection( + "Same origin redirection", + redirectUrl, + locationUrl, + initPolicy, + redirectPolicy, + getExpectation(expectations, initPolicy, "same-origin", redirectPolicy, "same-origin")); + + // Redirect to cross-origin URL + testReferrerAfterRedirection( + "Cross origin redirection", + redirectUrl, + crossLocationUrl, + initPolicy, + redirectPolicy, + getExpectation(expectations, initPolicy, "same-origin", redirectPolicy, "cross-origin")); + } +} + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-referrer.any.js b/test/wpt/tests/fetch/api/redirect/redirect-referrer.any.js new file mode 100644 index 0000000..99fda42 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-referrer.any.js @@ -0,0 +1,66 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function testReferrerAfterRedirection(desc, redirectUrl, redirectLocation, referrerPolicy, redirectReferrerPolicy, expectedReferrer) { + var url = redirectUrl; + var urlParameters = "?location=" + encodeURIComponent(redirectLocation); + + if (redirectReferrerPolicy) + urlParameters += "&redirect_referrerpolicy=" + redirectReferrerPolicy; + + var requestInit = {"redirect": "follow", "referrerPolicy": referrerPolicy}; + + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(response) { + assert_equals(response.status, 200, "Inspect header response's status is 200"); + assert_equals(response.headers.get("x-request-referer"), expectedReferrer ? expectedReferrer : null, "Check referrer header"); + }); + }, desc); +} + +var referrerOrigin = get_host_info().HTTP_ORIGIN + "/"; +var referrerUrl = location.href; + +var redirectUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?headers=referer"; +var crossLocationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +testReferrerAfterRedirection("Same origin redirection, empty init, unsafe-url redirect header ", redirectUrl, locationUrl, "", "unsafe-url", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, no-referrer-when-downgrade redirect header ", redirectUrl, locationUrl, "", "no-referrer-when-downgrade", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, same-origin redirect header ", redirectUrl, locationUrl, "", "same-origin", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, origin redirect header ", redirectUrl, locationUrl, "", "origin", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty init, origin-when-cross-origin redirect header ", redirectUrl, locationUrl, "", "origin-when-cross-origin", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, no-referrer redirect header ", redirectUrl, locationUrl, "", "no-referrer", null); +testReferrerAfterRedirection("Same origin redirection, empty init, strict-origin redirect header ", redirectUrl, locationUrl, "", "strict-origin", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty init, strict-origin-when-cross-origin redirect header ", redirectUrl, locationUrl, "", "strict-origin-when-cross-origin", referrerUrl); + +testReferrerAfterRedirection("Same origin redirection, empty redirect header, unsafe-url init ", redirectUrl, locationUrl, "unsafe-url", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, no-referrer-when-downgrade init ", redirectUrl, locationUrl, "no-referrer-when-downgrade", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, same-origin init ", redirectUrl, locationUrl, "same-origin", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, origin init ", redirectUrl, locationUrl, "origin", "", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, origin-when-cross-origin init ", redirectUrl, locationUrl, "origin-when-cross-origin", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, no-referrer init ", redirectUrl, locationUrl, "no-referrer", "", null); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, strict-origin init ", redirectUrl, locationUrl, "strict-origin", "", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, strict-origin-when-cross-origin init ", redirectUrl, locationUrl, "strict-origin-when-cross-origin", "", referrerUrl); + +testReferrerAfterRedirection("Cross origin redirection, empty init, unsafe-url redirect header ", redirectUrl, crossLocationUrl, "", "unsafe-url", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty init, no-referrer-when-downgrade redirect header ", redirectUrl, crossLocationUrl, "", "no-referrer-when-downgrade", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty init, same-origin redirect header ", redirectUrl, crossLocationUrl, "", "same-origin", null); +testReferrerAfterRedirection("Cross origin redirection, empty init, origin redirect header ", redirectUrl, crossLocationUrl, "", "origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, origin-when-cross-origin redirect header ", redirectUrl, crossLocationUrl, "", "origin-when-cross-origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, no-referrer redirect header ", redirectUrl, crossLocationUrl, "", "no-referrer", null); +testReferrerAfterRedirection("Cross origin redirection, empty init, strict-origin redirect header ", redirectUrl, crossLocationUrl, "", "strict-origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, strict-origin-when-cross-origin redirect header ", redirectUrl, crossLocationUrl, "", "strict-origin-when-cross-origin", referrerOrigin); + +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, unsafe-url init ", redirectUrl, crossLocationUrl, "unsafe-url", "", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, no-referrer-when-downgrade init ", redirectUrl, crossLocationUrl, "no-referrer-when-downgrade", "", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, same-origin init ", redirectUrl, crossLocationUrl, "same-origin", "", null); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, origin init ", redirectUrl, crossLocationUrl, "origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, origin-when-cross-origin init ", redirectUrl, crossLocationUrl, "origin-when-cross-origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, no-referrer init ", redirectUrl, crossLocationUrl, "no-referrer", "", null); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, strict-origin init ", redirectUrl, crossLocationUrl, "strict-origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, strict-origin-when-cross-origin init ", redirectUrl, crossLocationUrl, "strict-origin-when-cross-origin", "", referrerOrigin); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-schemes.any.js b/test/wpt/tests/fetch/api/redirect/redirect-schemes.any.js new file mode 100644 index 0000000..31ec124 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-schemes.any.js @@ -0,0 +1,19 @@ +// META: title=Fetch: handling different schemes in redirects +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +// All non-HTTP(S) schemes cannot survive redirects +var url = "../resources/redirect.py?location="; +var tests = [ + url + "mailto:a@a.com", + url + "data:,HI", + url + "facetime:a@a.org", + url + "about:blank", + url + "about:unicorn", + url + "blob:djfksfjs" +]; +tests.forEach(function(url) { + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url)) + }) +}) diff --git a/test/wpt/tests/fetch/api/redirect/redirect-to-dataurl.any.js b/test/wpt/tests/fetch/api/redirect/redirect-to-dataurl.any.js new file mode 100644 index 0000000..9d0f147 --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-to-dataurl.any.js @@ -0,0 +1,28 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +var dataURL = "data:text/plain;base64,cmVzcG9uc2UncyBib2R5"; +var body = "response's body"; +var contentType = "text/plain"; + +function redirectDataURL(desc, redirectUrl, mode) { + var url = redirectUrl + "?cors&location=" + encodeURIComponent(dataURL); + + var requestInit = {"mode": mode}; + + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + }, desc); +} + +var redirUrl = get_host_info().HTTP_ORIGIN + "/fetch/api/resources/redirect.py"; +var corsRedirUrl = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py"; + +redirectDataURL("Testing data URL loading after same-origin redirection (cors mode)", redirUrl, "cors"); +redirectDataURL("Testing data URL loading after same-origin redirection (no-cors mode)", redirUrl, "no-cors"); +redirectDataURL("Testing data URL loading after same-origin redirection (same-origin mode)", redirUrl, "same-origin"); + +redirectDataURL("Testing data URL loading after cross-origin redirection (cors mode)", corsRedirUrl, "cors"); +redirectDataURL("Testing data URL loading after cross-origin redirection (no-cors mode)", corsRedirUrl, "no-cors"); + +done(); diff --git a/test/wpt/tests/fetch/api/redirect/redirect-upload.h2.any.js b/test/wpt/tests/fetch/api/redirect/redirect-upload.h2.any.js new file mode 100644 index 0000000..521bd3a --- /dev/null +++ b/test/wpt/tests/fetch/api/redirect/redirect-upload.h2.any.js @@ -0,0 +1,33 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +const redirectUrl = RESOURCES_DIR + "redirect.h2.py"; +const redirectLocation = "top.txt"; + +async function fetchStreamRedirect(statusCode) { + const url = RESOURCES_DIR + "redirect.h2.py" + + `?redirect_status=${statusCode}&location=${redirectLocation}`; + const requestInit = {method: "POST"}; + requestInit["body"] = new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}); + requestInit.duplex = "half"; + return fetch(url, requestInit); +} + +promise_test(async () => { + const resp = await fetchStreamRedirect(303); + assert_equals(resp.status, 200); + assert_true(new URL(resp.url).pathname.endsWith(redirectLocation), + "Response's url should be the redirected one"); +}, "Fetch upload streaming should be accepted on 303"); + +for (const statusCode of [301, 302, 307, 308]) { + promise_test(t => { + return promise_rejects_js(t, TypeError, fetchStreamRedirect(statusCode)); + }, `Fetch upload streaming should fail on ${statusCode}`); +} diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination-frame.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination-frame.https.html new file mode 100644 index 0000000..f3f9f78 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination-frame.https.html @@ -0,0 +1,51 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination-iframe.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination-iframe.https.html new file mode 100644 index 0000000..1aa5a56 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination-iframe.https.html @@ -0,0 +1,51 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination-no-load-event.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination-no-load-event.https.html new file mode 100644 index 0000000..1778bf2 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination-no-load-event.https.html @@ -0,0 +1,124 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination-prefetch.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination-prefetch.https.html new file mode 100644 index 0000000..db99202 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination-prefetch.https.html @@ -0,0 +1,46 @@ + +Fetch destination test for prefetching + + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination-worker.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination-worker.https.html new file mode 100644 index 0000000..5935c1f --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination-worker.https.html @@ -0,0 +1,60 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/fetch-destination.https.html b/test/wpt/tests/fetch/api/request/destination/fetch-destination.https.html new file mode 100644 index 0000000..0094b0b --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/fetch-destination.https.html @@ -0,0 +1,435 @@ + +Fetch destination tests + + + + + + diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy b/test/wpt/tests/fetch/api/request/destination/resources/dummy new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy.es b/test/wpt/tests/fetch/api/request/destination/resources/dummy.es new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy.es.headers b/test/wpt/tests/fetch/api/request/destination/resources/dummy.es.headers new file mode 100644 index 0000000..9bb8bad --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/dummy.es.headers @@ -0,0 +1 @@ +Content-Type: text/event-stream diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy.html b/test/wpt/tests/fetch/api/request/destination/resources/dummy.html new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy.png b/test/wpt/tests/fetch/api/request/destination/resources/dummy.png new file mode 100644 index 0000000000000000000000000000000000000000..01c9666a8de9d5535615aff830810e5df4b2156f GIT binary patch literal 18299 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 bDgs((CoOJ&@$4~l+kmsZyIqm(x-I_(KvUo& literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy.ttf b/test/wpt/tests/fetch/api/request/destination/resources/dummy.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9023592ef5aa83a03dd6957398897a585062ca57 GIT binary patch literal 2528 zcmds3+iM(E9RAMC>}--IZHkSlbcJb}Hmx+fn~4|=c}SaNsWzb@-3F9mI_^#~3%fJR z?rbg~7_kpEQi_7Nf1zrjK~Q`sQnW2nkwV|M-@%oK-E?>x_(MpM29?acOqAH}eAZX8>i{v90{QD@UK8 z?l;0S4h7BS^-meAn|!xZ@)uj54c;RECHbzRm$TF>nv6e6K2fq3%b3Ch^~cB?u2r(v zkH7ad5c`2P>9SY#cXvOjFn>GsdB|P~`n~De%#NWyu}z}@xPEE<^GzId1b6hq`YrNJ zpl7(G&#mANPHPA{)^6*E!$^@bL|Q0mLqc}TB|Swb8%8pe2%7wX7*!uCHz~QWfyJ-r z7tPW^=Wn!Ro%h$|>{uSdXvUa|I&fOQrS7LPvS9~Z(v&zMA=;65&=C=`DhY|mZ&Kj!3HhbNxDPn<$e@rAG|9>`>_U%Yaa|m@d0+T+9$}A07}*9%}w^Ondom zl3bI=hUcy>--uAx7wLGEX&Kzn_%3s9JFtg_4YL!pi){|FXP{H;ZW!kJ85v$FZVvZc z=7R1t%y+FTKz4K3D>LVrE9j_0Kg^W>kV`b=3OX8csX3V|_H9EhakU|rxWPtGG$xDs zeRLLqoPhkgUkcxKN!R$Lr8Sp8i&%_ketg8+5v}5Y_$i__v?%)`I)-*-GNN_L7dTC! z$#2##gbi9?mv|+j6|{;sB3i|`_#mP+>{8kyItD{YMzl`3g%NltV+j=$Fb4-d3>-ub zhlow2(MK>aNlgJoLYZ6^7Cnmetngdg!aW6>yiIwPzj@l!;1b)kFc{MzW$@m3p1uag z87D`H8(I%iBJ=u;J%|+dLb#J*WgAu=<5fZ*DXp;5R9MY}C{;>IjO(NK5lxbD9RfzY z@=~QR=lI6K+#$nE_oaaWNmxCd;m?tPvxY zJ8xC9c9rx5g?W}XCSRSBcZdsV~Z&>YL1E97mjWcg0TE4dJ(nei;|UEZ=>^4@JlJ9c3=csI~@nRO6C WZTNf5{_GpcUH|0vRERIFfAlvXi@|mP literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy_audio.mp3 b/test/wpt/tests/fetch/api/request/destination/resources/dummy_audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0091330f1ecd9ba5a70c6e5e6da284a061e2b99f GIT binary patch literal 20498 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 literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy_video.mp4 b/test/wpt/tests/fetch/api/request/destination/resources/dummy_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7022e75c15ee5263f64bf5c2d86b19b72d2ab5f9 GIT binary patch literal 67369 zcmX_n1za3G(Dxz5-QA_QyIYas?r^xfyB3GyE=7w&(c-QJio3hJ%YE19ecx~Ix4V;M zCYxktlmEspHOrWto;V#wD_4%8N@cc5Y5GCNfh;7jrT;PF^w>b{;-1G9VDh2L$qh z3uKtV3@i%DQqoMEWMY~UU`{i0Q*eQVqm#FtxrG}UJ1Z*(6FVy#4>;4x&CQ9Qg~ij; zli9=C%-qoq=)mmgV#)IFDa=-G_I6+%M<+LHM+aAaGE<-l&{T+x%*EV7h=a_`+{Dh& z)K-X%pOv4L4Cnx~^L8~CV)f$SXZ2!Z;~=v)7qT+&0aH9=POjcyQSjBs#Y~8e znH4MqzL42ldzqUV{i~4;EMepVbg(oRV&f(=wQ_N^2O5Dz*~r{n%6#LmXZ#ztlVbagXwa<#Q~`p5A<6*xN?Ia*k_n!5=xv6H!3 zxqu~H!L6}#bhHIpffGjmm&ZotYG-WYFl}un#15u` zWFBkboy8Ee|Mml5H}wXZ^mq^-yJ^4NP(&dGE<@S|>YO4e!q$C3^s1(|^_&Y9M5 zPf9`>rpqk{a|Qhm_wPBbjE7O&58m!qsTmVR;h9Z$PJEqI)02{O6DMpE8bs5BXQCL7 z1F5&8AgEueBTU7XFq0DVi0|iCBs?_S%$9Z$ z7|}Rlpy0#N;J7D4Sfk3^m)yDgvoyc`ki*@ylGMhSk|T=3^kr1Kp1B|-u8h4o=K77^ zETM@t`dGt(V=&rLZTtEAc<)RA@xd&gh5B%o9x(HC`ou2~F@%eJdAsr`laVqS>(*QI zhfYmCgn(!Hqvn`zRy=Uv0Lr@^hqD#=fWE?h^wUa39pV&;F~z3-aV7J43!=tWLR6&^ z3cEXn3VT?bOWX~1=~f4Cl}hatQ^f!X6Lqwz1M%@%T;?2Zv`|sE#7ICHIw|<~)tDS} zSHI5)xgKoF17DFcqWb}BtERNL{Uyla_YdEV%t~K0dE~jl1an*o$woYgReWhKn^O4W z*v1TU3+9wBr$^2cIg_p2Xu99C#;7gveze#c6X@l;l=jn(Jg4X?qV`GHAy(;LGiW^l zfUs|vpMTJbpCAWOp%H6WWC&y8KsrKu%O(c>a4Bz%3qWE-x`ut95Y;?4NSlygugo#>u3f`EOFKbCxuGuC{8IjkygTOr`${xnzm z9V*jPWJr@Y;1~Z{Mxm$w>uj~kWz1hG?lW<>W8z|S&pF{@GfCxqAA|%p zDdY<-k<%-yxSWZ=2!5{O!f%RiqbpP;BS9cA`KV0y1QbjU@V zup4`(ViCIW&+%~7I;CA`4t@3Ua*4T<2-h4F_OMYAE;~%*WJ@X*{TeD3Fiu0>d(QOM zZpr9Zy)8eX`AiVGq|$v1c}KNZc5IH3KSj?*U za>xGW!Ikp#u%QSq&B^N`Udg)D=W=Pc#Q_C3YVNUL=XRZKoQtNTiG_jcRC0>V>65_t zI(TeiLOA9`bfkDb5tRhP5f`uT_$%UAIV&*mX+gD}z~LYMaNRMuJt}s*qEiMC_lnH| zjpbZKhQz>n1~-IPn&?oEnog88A#%eFt&IYn5$=Hv)AoJ&aW5FvTatH3q<2#Oz5&}u zJaN!hUEIlt?y)80=0+L3WEz=-*S4LCLPv^H++d{-q+T&~jTvrXmH&_yc=K zF*_Ug4qEK=*uC$Suxk@WCB4@^-{-$pS?Z%O2-olxT})bh7c>{u!?lu$yZ-~_kKHs% zYwX3wf0^ILa(<42#C08;9#wdwu;?qL4rmfc_vcZq`{RiHWQ;|YTROI@;{D3}DlLj0 zSE!uoqL;&CC3lKl(7>paa>2jVd&X*_@cbKVlG=mqkN)2FA6?_sok9d^nO@qTbpBt; z%IDXIU5ZLY$HJ4kX`EVe$5&6pRpcWgw4S^=Wv4bzeQEtlBkSq*pQTPA-k7jsFflsC z`OSsAjme@9t|-k%;zLFfYgvE$YWk=0;`23mib7$m_{f)KqE?*eb zmN!~0$~{TPvp|2y*lEjim@n!-7WRDJUT`dPC~X)|@bwqMt6NFJBTBpIZzB_c@fM4U^8=( zn+zJj_|%98q`z%5Xrf2B9~ur~D)Isd>+=XVEtn*B3G8!U9QvA+>;!&12prK==^tn~ zn=WPJBY)Rr6<_F|taDD)4vMI-$h9l`{ntDU?F*1t474Ynk)_5?`q;f-Ip3pV4SN33 z^Y;hp0`Mb({D%muln0d2U@3C-t#lHPonBHIk;&!WOJSLiqxr>}p+WK>+=?KZC!b&F zZ}@Kpz#{ z$OLXoJLq86JJ7zaSv11*<)81&@ANe1&>}|4sdn~Am*mdap9o^yJ^Bxaw7*wb8@uNX zW`h8aV?zB&pQD`b(;e`|0t0WkH}i-6vTHibs!_DXFl}{S?V0_oDF=2d60ARvt-k(+ zg#SoX;^367q3KTB6IX8)F%orlPPto3@FbTWO^{r=BW{A@?h451WTrD0^woDp!S=8d z@j7QYq{1A7zB1Kg!qrLAxh4H%O5ruUB@{jFvTCY7qGl3c%)(ZkfSaxv^_7Q)7`?AiylQzx$d`W4Lk@@o|FX9k)c*}c}%`4A6F~R z;_O;O`+2M(iG@yDSvqcQ-24uFnNPLD&QHvn&grO3-mJUpE2|WZd*4Xv{-BhP{etNR zAQw$Gsm+?5Be)MJJ)C!t(yCaCW`F$sfr@UYw9kM_nlc(WA3z`sx#4QFe2=rNNXBnh zNug?;Ai?X^Mi(F}X9+L0-U3b6p%K(&6^h}cOOP3!RB2%GJW7#jq0z&tVS_yl7 zH_O+iSq|THO)9-4jzU!InVlv?f?Yn17^d}YXH)RRLFjy zFbuYg2duf9ZMl154;x-E+(sk;o7?AGc==)2qYz6woQ^`sxCLfw~7 z3#hnsh_;(})o#uCDY-b}*vkcKNdlt08TUC%IOskEQ+9+yOWqG9xh1{10uc=Y>X#u$V%BJ|A50-?-2GpHZi*P;e$SK$w)}2 zyJdUL1o+Fs?G{s%X8-v0uempL9W)U$Z53sy$B(E*x4#9hgXY6Kvjv1^Ewr*9$aEg& z#Gk(8Ya;lD(Um6V_?JB2SmeuOnN$G{3bs{*yO5gaeMC+Ufr6F4KVE#c$7#Y#RjD$` za}*a2{IM!5JS3tQ18Pf=v<_K?H#ibm7%Ww4s1Ap8=4{c)q;gegvOO(?D`AsmKiHYx zD=C>jsRHr<*ZGCOed~D->&M7ABmH*8a@p`Ri~v6Xh;&+_TTk|LL_?Z6RdZHzMwLis z8N-q4wm-y8@H6j+t^Y@4CfDQ_b}Bn5oZN9AG#%QB%=)4IWmTtN#h=)-QAv9yjulus zwN1$&jp)_^0EiT^EJ{0xz>l&pz}ry83C**XEvD$UT$aei{MljIYs*!W4+s4-mvfUL()0GXeumWS{6ZTN=i*Hx=_YIwd&y@;7C!;_y_0 zro-Rx`SXe54tj|q`U@3->u==a9yjoF3Nf#_7Q3W~e}V}{GZOh~&`948gN2Q{Qj#Qi z3NSd$c9q#oAL^7P?g95HKYz)2#47dtB}_RgiCypXH=VZ5#K+z+iM-lTjEB+qq=_a* zWFJMLq-{0v>hN>y>!HOE6MU}F=0yBtX~}k3e)R&Hq4zqhN7{GTeZKbX>uw?_)&%QG zqpS{7VmM?DkX!mlcTSRY>Jw>tkfj=7n$(IUXcMAAnQ@`{4H!S*UfJ>2@mjRV-NN0y z1dibsdmX(iay@M9Ym(mm1Had-{GQtl36XclriX+=VUR>UE1-bVu*U~RWKq4rfkS*| z0~#O?4zY3ZFtf4#J6QpctU)N?VB4@*o|sp7AfUfcs4McbbT+3owu74z#uyqzI~HU& zg@l0GeZ*}b{ybxE070d+$|qz!<9A$1g=$NGl5umIAa>V9M+REz=t!u`Cx7XEd|P#Q zWLwQ*ElN!mEJdYae3@(-?ib%gksR8Rj2Ze>!fabsc;aIt1x>&oUL;|-Bf$*blNwmh z2-90ex7Za6(r^3B^1R4pjB%_^dt%PS?_M$FD~!_2sEjoyk7z!1n7>^fo8&>b>a-7jo9AA%r7OZp!e0(dE)?6a)d{24!?6FgZc zJ5ES(A_(!Um2;=kkM7#T;lX|$JC{!jX#2yz>AHOG z#9bmI5{a9Rt0@|#?~G{vDc|I@eWBguF0XUZ%Qmj!c65XOg^46a+THWtk6yprrDXkU zDzo};)n_-ikd7)bjLh+Ezmqe@M^lkV5DM>W_>?E%u3s&OIZ|0d%!UwRTCrm}2ruf> zW_$Dzt#QDNAeAXP-3=N?LeHb8+`Gx)T3~+1DAnf2{Hyej(*fIR02*1>wYm+c>*fb} z8U%HoQrl>h*-8S(2vozTglyet4H#_Q$f$v{JZ$a5mi!|Z$==ZT2AEJ`tmO3T2P;0? z<7osFYo1w~0RW+VPWPZ0q)4l|#_s$=&ptaj=NO6ECEQ}hD+-cmNF2XemZH2=>X;}5 z8MT2Z6?vd;@{`fchv$JO%pdDN-qjO|CK-@dqmp=UVM9?~et$(_#-Kw?q*0RJ9h}ES zN+ewW*r*Nk6!Db&fH$&sy<^=^99p&>w~Y}k|9YhPo#(7W(Y;)HYZ;Ctn*la*cg9$B zY@kI-4fC;BQB?wa1t9}}Sx3EGmsb|r=r;V z3;O!f-TW$xMV6Qllnf~(hbmVey3!p|D657_6H1B}CG}V<`&6BrAdwvYI`aia2&)4_ zRtyW4oZwh$O;P3M*%*^GpnXOA4x8R#=h+^bFNJ^ggKsIYuji{JEq|E+|1m8Rh>s$= zp0mo^Y(u(-Pd5s7#$hWkbqANiidmLH*VcA8xr*fN`+duXf1B@+ew~%s3k576CT>P| z(V<~OedPCi5IWf?K;#KT_|{$XcDWXrQv6Mv0-YQ>Qc?y08h$aVes{(!PK=b4ph8bX zgNLU2LLmm@j(XP}8VVgfCv!xhmh?F?Y#^4M?9&_rCn@n&J)6E7v>G@*30Q~7GqDj5 z1SBKQ3XDSurf2UWbq2rzn*MN|#!j=KoutBX!Zo%%tv4dh9I^zu|sU+O|7s z;D&pJJsJ#uZLIos`RmdyD2wPP!HeM#)P8#Cx8J1lEyyv(@LIQvYf)W{%eFg^Ne?KI z3y0=VT}IA;O#1N8D6D`z{S@x(5t5s#P=}>P7)^Cu6!hX%$kVNX$I(yMc!K~GC9i3f z>@C<|cYoB-=r*2EGkc{`Rqae7oBF8M<0MI_x00Y>YmOCi`6puH_T| zfmTWNU;w2wG6drT6PDzYVP|K6-$$*_aq3y^=6L7Mz4^R`t&SENivH`Tya3BA$uV`e zqYy#5W}C>ZQ}iF7W3{Pt^BPiy%Bd_Avbi}`d&M+rx^)HM=&EBDGCuEm8@<#DaG@0x zlzsZ7{NL50r>Gk8)Zh>=BqSYfj`yUfRtk|_6@dY6j?+aRYl*q!G5HfQpAC*Wg8@yL zy@@AeNLh8cm}@bX%HNQN&&6~30CnDQFDA^ncAYBk(rAV;G1+l-`l^00s}z1uT3S8! zII9?-gpQ_^jN?PFQ@LCp2jQinyW6BWiIuU4g%A4hK_Gbq{G@zKOu;5`4x7n~_{cz9LY9@ibC&Lt5I~9kMXKA;F zDfsMX=&SnA6g}Cwd^~g473AIvV&p~jfefo0VK*hejaIzX!EFwFbP_EE0I^{jrZD<) z@E!Z9jtsJ9UAcndJJ7>VtbR&z@?4RrL)nA&x$qSdT{c@H^mL^as1}755;zjRw?nFQ9A~b$Xw%x zug*y;doNs))3sv1v5JmydZysv`W~OuYVhrAU~1)v1QQR_9*ml9VTz-H*`N(-oTG{h z+ouw!Kj@-vZJlA$!g%AWDyn~>P9gt-MnTT-ZPklL98M--Y#p%J@rviYsFCuBltorM zNZ~oZP{Qq#V;|pw$&2RWM0+8ixtV0I&}Mg^&R+AGsjWS!H(mziGgVvnwRDhKmu1j^ z-(ev>g(`7O2aRiE(ArVwy>ll&6F%$G*6pdbhE4IExbo&qvKV&yTr(`zT6%py9SMer z3&kKdU4&tdq%3K4`K?J*iPDd2*zBl)TYKNtteH24q%#J`YKy~IDcTl5S`-J_c4@ZA z69P@gvflo}^bi2*KfBh=`@?J&RElbH<(K^Qk?G!6J;sKCHiwCEWk`~;{Q8d9iDbp} z$0Z>suxAbcK(c)pj(7vTfX2ta`wAP!9FSg#D1(9^!im;jg#$(MCpp#tBCF>Q#sKKI z4?n;iH(DBJ=4kCy7isO0q4QzY+pF!yhtLPeoS|mCBfedfDvkuY0h#*nBS7hy`Gzs@ zEDsk00NPK}G5@6oF&2r3hnH4U_-M!F0PgT1-_wvR ze^(^?p6gS)W9xHXZBV<~NB)lB1sXr;L!6n|kH^D%zaVz&k5KUJ1_29^7xr&$tnaS6j)WbEe@Z_At5b;60 zyNO)-DPi?AvdISDa4$Q^RpRk38H>g=%yc0Wd1(ks|0_!7?gS^Wtu}sa6((w zs)Q8o9}X%>rk6%Xvm%y=M%;0aaW9P6@k_T?^Y6+hy1LLVx0S%4U~&X@y+h#Df~+8Z z%$a0lMVdA4;g0DD(59G@prrQCj@_oHitIN@4z$f(mWp-T;6JlB9i$S)9(-AATvTt9 zk7r2MUKYdmG(qLCx4wf71W!LvLm^98b7hOK?%FgLhrG^-iw4oOgt}q&KSAJeLVUyp9 z+^x5JoKMpxt~0pN(u9h>@4iQ-!^v842p2|E~JyMpcx zTzRVj(~RD(;Fmcl@v5V^%*XY&8~MwfsIK7@gBE(+T8-EJ;tNzn)CT8z%uZHxX8eNn zYq-%ty!X2MisUP|aM+s~soP6|KNH_sPI#-?qPF4O;(-eS=Dv-@0i_eVe~D!u)uAJ^ z{J{q%Qvs`42p~y(D;=r(xQL7quC=L_rxAJ)YfOvMgE+RS7@vBDua3-=+@e`hSuPJX z$t$WkuonW7oAFNlN$(+0&2Z*6J91l#oR$Y~6PInOc`L6hgA=!{T^*4!aSPh7`~E88 z+UI#vV$M4N69(5)Z|*$0KfY@CONFY{h|Ir3wm+DbUY(apM&|qXtPlry_zap%ZMoE! zHMfNsZC~})=fHeY>)klS!#9IYPjLs+)OCx8NU;&D4&q~}D)+Q4OH^mj#773x%PO7p zY(TlCZCS1ShWe)P;-b5-zi$_hK^vYmG#tMT;uYf@QIWD0LIwj;qByi-^~1uAB1v9X zH>&;h(DO%Nyu-)stN&R`0@TPA-w=!e)b}HzU;t#UO-La^4g?z5rrnPQ6v?DSW~|CVgA z`NP7t4sXMtcygGjIo`J~4mCXu9{Mz?C0Qn`K<$ombHN`sR7Z642YR=Cd{pcH$sQ~w zckZ1DijvHKFZAIPkMX|Iw=qx~oFRxSUR6XU-oXZ_ULAOp&1PmdW#halx{3{3ubgOl z(@lI{KNlXcI$~JOMD6h{#x`X;_?SZtt6wDDtGf->(raO+P|BA@kV zzsylW1g7b1N7Wax>7Rs+N0a9z`^2%IuvP?Pxfe3R;P0i9UoP7$<$caGw1!hQhqu(w zuY-@gg4Mzv(1AcoA0c>)hjzViUe{pMdA%-zZbM?aUrWlm#qbbt+)Q*(S+h5SPwnY0 znq}4Qm)E!V$JIWw0p6Jl$Kr@Ed6wk|J^<>_L+B3tXJ*=0={I`JYY@z~HK2&I2?~4= z!dzEo6Cokl5$ZvmaB4+CDgPKtwY1-5eZb(!a1WTTcwW>%xYN5BQPoQr2pE1x9dajw z#htFZPS;Y3098IJX&_^~N94F0!Sq<(cc1z^ZYQd1a9U^bY4ZLVM_>hBS2k=qbN2^f^Y}l`Eklx_JZ|=!}hPoh*jL+U&M3T>qF*V3!e?E2} zCI#gP8Vd;ZpOnigO{DsGoKry6#EgnXETkv>Ry@@WBF29v=r!oAr3@UY4fmGEr0D#N z#$0x5>v9bv5h=3f03tI6FEn7#AQvBE5?q07y1Ry{cY9rXJOuWoKQ;mnKe}&0Uk?*V z4Vw~#s}DCgsnZPDl}I~j4^XSkx??RTTnvkcuUbVx!+XXq|impY4@blKkzbA?+-ZM7L09;f2`?3JU zoJUan#d;8A@}RH>)+gulO_G%u0DN9cOM`Y0yhA{QcCh1l;Fp^b?A*YzgE_>94<7YT zrysLwtytraEg8jtvK>4?*%`0^=S)Pp=YP6p4m}7#)}u~KetkjER{egNu)6y$@+JuC z29dsiRy?cUeaiD}Gj$iX;9nHWV`JKuNpN4V$scBKC?pcD4bgwx1_ss)=-Y#dF-px@?po>Pa^^fz4JnUCjKKBKsM@tT0vteSnLzVd zcllE@w})Dw%%ZbU#F9QevlbQ5G#}7cTwCqzwfb!bfeYFZ7fwV2*U8kExw7^el7hqxUNMP&|+j*cbQ0p|HjPN*y0vy^SGX2U~4$s%BjSGT$$ZI zpQHpW;3#jLzUwPep3KuBlz#u=j%}C9ui>*Pp_f9(z>hbh{~^jh(k#Wri(t=74$9iah!L zf7OoQt}gI;E(Fd$!ER#aY zAMskJJM1Dim8y}4hI~t2;BBrPPvMUymlsX%d-!t3i+G~jVPd%oYrzAl#`7Ht{dy)M zakJU}N-rhQ>Pf8vAEDk60X0yJU=F{aDme!(!>#U1Z5}3a<)@4FPv^9KvB;tpF1u>Z z{-19VcG8?V<&V(5;#&BM9nL(by7LK}T28v`3=S7rpe`m35sp-uV?~kov5IHXiFjFe zMDWFYp%*JSpDS)_7U~kT4T!P`*Q^ztzypatPO34mY$Sbs*w`rj^+%sR&m)-3w5s1$ zKAIzO12YsEL0Y6QTKD%Gb+pZ7dS|i!-}jtKY+Mw@feKr7^vzOPM|uTfyxm3}P8WZV zKSsUQ+;@JNO*`J59kLIEDxE|YOXC{u0`sT`@R2&7PgF|B7)g8ru{IIxa8C0RKi%l@ zdmiXN3)ametc*dSvhaK+t^2;Mes9MruC^W;&ozM8+ugz<{=HD#D(sH!Fao~wJAOgr z0uLS>=IQQLEtkBQnM-i(!t7x7z|gYUBTS@%NYbI}CaTT6%2U$PH&#QoBpiR_y4VJ@ zZ*F*8%TJ4_1ADlXOMj`u=ncnQ!<@=YC!F|og9LGzDx;&wAAD$jk^Z*qdW${x%Gz_> zz#G4smLG!nQJmB2-K`SL8lHTW+mls$371i#d$7E=1#a>qM$K|E{YS3M8 zsgux63>2JkT~wvnc9$T8fuYnH>_opT1oZ8(s_(yjlO_`W5@54uzJ-JUsKWOu%k_wv z_tv$eosgl-@fd?0jq^otciHt5^D69XtglJCPU1gw2nqVy_zGv)Ibzz`-)%VTEim&+ zN>C1-MLru7QlT^21ePV#KYBgnK95q>Ytf&ir{|s~G+b^JK(qXQt+c;}e4rpeod%Mo zkS3`LF!QiSo%TH`y@JR?1AS4D*nh~~pDUC{L19p%9Yt7-QX2P6o8qRF5O&Dm??jJd zXSO}%r>Gz~VjSyZ@88zXYL5Cx3y*(d8wN(==LeDoPSWlBJ|u6LG`%U4)y!fE(RJ^1 z*LKN!E`Uy}a~UZRrWMG=aabN@Dt^^2UEY>2{4{c(X;E@~_~-Z(M3Orpw-A9Q z^5$qGeW6hP$$V_XWJa7SSSJNHHCy8eP%d$O;CM@f2IhNyE9p-A>Ti|!L#rceu|N5& zX!DQOzIDf0wDu7Y1~ zd~R(m44!o7T!joJ;6}$55lu4Eaqp698C>eSZw1p9^RR z$f0l?Swty-zFkpscY4FQH(E%?%<&7Bt_re2iu_h;{8WEj^ErWSW(Q^=oG}gUnzozFE^WbKoz#9+>E|aQ{wDNfy44? zW7}{*O#MpA5d#sVxA+WS+wgVddGli*1Hi6MzxdG0mH2$kCIg4KZvO_kHc=czk>Rm{*(dc(^`4 zRq*O+N10Ke8Y=$fBW~#YJfQS+>Ss@TDo=_S3RTu64~C%2bv9U7NJvQ0t=HT4{lRhB zFxwvam0aHxgM(mp3*1nGAPt}jM3dS-@@MB@0?*$?%I08|j^D2I z3al(&0tGRSh@nO#2Ni@Q(-Zb-y}@L+}> z2n=qxG|8mzYpbi%=f^cX`PGiyH6k%j&+h>n8w_7mZRcM6HX#o4QgZWRmD)ZGWCKb? z&?P&hd-tt-p+kd%gV!ndZo4*K6TuOKj*F+iM$ywY$9^PR04S7;r_+Lhg7WpMjl;e& zkjV2(Q!9MMrr~270D6j9|F`@Igx7Fj`})P@O9n$NsCM7IXYMW`(gWborX_c*9qixz z4nnn~cM%RAfm|g-gQ%^oHJFq906o0}ThP)<3y>8So-JtG0!NK-z=HPAPzsldge@~9%oE0M{C+CoWqbCWWIg(M} zJU(6xnBPwo4+#m$@^PvL@4*njQKp^tVX=U!Io4kaF+lt{E%8@Vi&eVq#4X&06}kAx z22H&aS700)Ff^)6@ez_5M^I=e-fr9(M&m@kZKO`HBV|`FT9=FFKN!X=C({aVf`%)n zawXhn+B5*Ku#|JW;6c5E$Vd7I5r5W0ODAhT{Dl1T``h~p5?~~7+nEm$2s|eUR!5wX zIN*W)WJ`E#7R}4Ee)~Otq;2=Bua?bN1>;z9{ei4u2K=3r$@GPZ31WuLHxN8s^`DYC zN)p>TgaKML(=1An-~+$3`mYY6bqm_HVwK!EkIij6wKlzei+CI_RQ#|V z;sQhwQ{v|@+OUDWxrF*EvM}DIMH|MJ0%-@f(iuvyLhz~Myxm4U>7%{o#6*dOn%1QO zQW7FTj%L4xXaWHXW3{21EG0(#aurx$dQntxS}F4tJYrDC@Rw^$-Sr%>6;J{ zpX@6A-SG3KN?gCm*Y|y47%-%+(j*tv4cMJ;PL4b9v$Z9paj3M>s=Bltg&dTk8X)W7y=ZecVJ84ocKYUha645we!IM|Y)p8xlGUfif6Q$9d_ns( z{N|l}yrvXxCDdtSY-}R>XJ&3l9~|FxN0EmehHB}ULmD~kNJvE2Y8ijk^&&bP;ct=H-|IEpy z^PEtLfRS-5SE@=#tSU+R{y4Dt@^~oZ#_A#U*!I59^?mDRBAVrADC@URZG3k!K8eu~ zVLC_pr{Q8U=eO`@gu5yNbjt&_iET>*#S(AHYOW$4;(akGWNtM#BE)FJ+{s6zV>?p6 zvF!X9H9GURQqR*^#|Ad4XCdUuZS^FPEhp{T=WXj3wCg9D+5gU zX&0$40e8o92ZZf(y$PxDd6b+to|WheeWC- zbdyzea#3|1lvg*`9@6Q^8FZ1lo*#}!p1+&idF&ML)TZXG!Wkx`v;t(A%qMAKbNYiA znfcH4g6r?s?RM2$c&cSL67>G)TKD_tE^7(fP1P;^5ZVudn4Ll;QxWWR3GlmfX4y;J zOEMgc>f5nMKo!{sG5#8-&w$~f`>l`-lfH#LaTrzPZ{50omP16;c`k^8Ns=xZSlC<^ z)yS)MCBk=4vQpp_P?R5rM7qTDj;cuesUxD;&0j-&QHFYkOMt zPvaNZ;b)4{h~VdUWhUlY+x%aibWaI8Zo(N*p<2x$KSB!v@{?e`?LNJM4a!0+pG6A$ ze-IK!U^3oA=H6R=5EbCpes778RLV)If)KD#R4;J%f*W<Cus)P1%E32C~GP*^ET_9O29!3(q&)@^>23`5c75+)obK zVon#5kr7u63wz1!K7MxvTroHjMI%O92r}kRO!^JPw)9O=xFb@)(GE#5pmLFI}B1`1y zcV844Xe06nV!#K#B@QR!j2B|8jqY(2`3w*(Yj|^Nn{m!vRSIRnlv&6rhPQG@^4i#4SQ^5aI=RAp7$ z1XpT5ujlotp8i0Mpzg`7D9~6Ln+A=0^DJyHAU?{=^TtFjfrwG)v3tnW64g<#y$X}s znAOC#HEim=Up{JYi#%f|Hn>dbqDFs$6s5ao%jHsdTwfPjSx(LH;D@Rxc09hZIc8`zAsD_RYUb zVu|}np4EyUb}T^cCKtoSq!qeNEtnQ#kFy$(9zoSiU8!A8Rig#@Gikm`L+^ApaOLk; z%?739Rx+iF#!G1H67%F=8dzMmW|!g0;$Wr;$H>{@=)utTRkYdSMtQ{s=&l6^`<_pv zp3kMv)d1AA%Ugv$0MXm(FU}VPQhpl z7H-0MHNUGiHpt<;&F{azAo%Vk*PYP?&FoPOYYSY6hxkekFDDA{vzxgu1wr6&lNl2d zqs7Pa2$ND?cn+j1+0VDXzC9mOWlEOidH?;S_qzHzKzolt{PosN`quGjkaFY6ZJg=A zZd{oBd^<~QAkTaF#QQMM`>^t%%io9b%shL=XvdSWuQR-?&Yh^-F?pQezrs@LddY{@FP#08W6(Et&%Ry>BS$-}KMY1kKFdoWkYRz}*+H12W9 zf4$8GEeH45HNuE0DKLQ=MSs9y30#3GnuG^8bVPJ>~ zM^0?^Es$h2DbXw2zK%&@BQsNKS#f5JWMXvn>PO36Y%hIq9KFRFpYn?g_*%&j5VvT( z?FQCoaqR_e`XAoKdoc(n?!V%N(&U>i0FFcg{7FSI&)d3zai#co0uEK$Es96}6qT`H z09|U`gVLZ>ABq1c#E}NoeDVbTe04!ATjPHLj3wwJ4-Fz&8S-b0RS<}S^cScqMjC%K`zuj6JH+Y_#TK`{J?q1z&q@26|E1~L1Eq6Qoxo;yvn zRv-?fcKT7D(xx-Hc|^*m;-nc_b8F+dn1c@WqR<9*67;PkC)Qg0VqI3$MWU{qwoBI~ zD_cwGyI*AtkC|TgMn6?Z`ZIA0r>#Wc)z?-lQy=vu(XFNbGuC>=3L{V^|T+e2P-t#bkUA>rRZ+?gIoVk)LD z-kF$|9Y6Ua`_}cB=1Ejfb!m|mDkfxewtL_r;5`msrBEUAX?V#aR4v`GPq|Xot%!;< zv>Nr0!I=PJO3OI?g#s7Wq}|8NgQc$SPf&yNw{stjQADcZS7f=}H8-Iz4mK2GMtOaV z0oSWd9P8IaEJ0M0l?GBk&BEU*sVL~Dyr~3Gml$gG1jd~F7p}pkk}#%x)l?CA#`P?E zhB4Xj@tj{xRH%$wX7lUix4Dh{MG5I&#Er3mX#=DeluzlMX>m6Bd=JdPh%_Gfv}IUR z*JJ2p-a5LMBp^`n=8t$?e`&MH{1!3ybAGI?@5-Qe;;VF0Dc4X*%5B!FC_;^g+AitQ zuLI9+6Uary`vdwulyCH&+U`*eYs(FV$5BQt5VL`sDPQP5rckS`7KB`Xf9HVHmag!l$dUuu5 z?gg6mvXPZA`aF-NE`=4|&dj3TGLDlcFB;={^nAt4bpo{7zVMPlIr`M((35@{Nb*p1 z9jU~$9+L9aW>oMGqYS zvZejDTm0tvWJCQ%`LT>~J2@WShKy{FY#c}l1*OTJ=xzSxIzx7-$D`j{tCO%eE$Ywu zk%%_F@FPcqDU_c|#2wKB@BEbzsYEj`3bA~k#LGwzUG-C!^pss$lL6|xQB3bF=UHa; z^v7Z;q@V%mxr?(X|76^;5M}d67eB5vYe!F`oJ;_^%vs8}$n$A`B`jDwc`-@bbzju=?*H#W$TAw-zE;x7^BTPewMZa^hLI0 zjnorFtZv7X2iZ@3zaA4O0X_$i*#fn+tW(7*B>wJxD%Prhr#Fi-HBs9zk*R3l@t5N2 zvlz?Do=X^JydxHuiRa_Dv^dwLinOZxIVQH8-m2;DFZr#A%!;Y`5M^YKexJ6e4%*kg zz%V2`2Uh+e(wMVwp48ixNuhM{LN zBW6oaf?otAO=jHHu0U)O$F#)i=2m{8wK~^ zATs<|Lv2ONZE({ zg9;R_r9kN?1EI(Sx!2u8=Kodn<;2E7 zVN4LuX5+IoiA70Nq18_54m*Dy81SS4AAw6cqqdw?5|1 zLE{EMS}ZxutuO)5D~mTBJ*s-ZusI((c*q36%lTBQZaCSR@;m%-ln<#Q2D zKQCPBNk`%PQF;PA{61JDvr1OqWd6%!#uYRNJY8$28XT3JqIdhWfDa`DJVw zQ*H7IAKrm(O#T=lZSJt^N~C*W@v%?Bjm-u`V5MBPr3+h|`9yk9Gt?W^Aq>aal1HW;^HM)Lndnicz752yzu|1^IapK#I- zO2vhfh5n@<|AMm1a02nkZugv1MW4{CdP3ynniV;o43J(CUy^#4AqM^|cWiE%WwYQJ z>g3jwF(uKT{zOX#E0j;P?%ju0sk6omf>0?gztrZ`z{{0fou%&B?;v^{&Mrl%sl3nd zbZ7mprstPnU_(lsy-KOg25on%@XIUe>Jf>ig@_iRzNgWtkI>tfHS)|?(K6gkRT)Ma zF6ow~R@#&OhtFGz-z$WodQ&kk&XBtCA#{`23p49{BIPEJwukIOWhVNwMZ)y4$z;Qw z$N^dWyh;rt65Qq*{>)_!qLfJgH{nJPsSLkhIkhUkEiiiZ=d;dWa#z{rZ7;rKRSTOHsxL{{OGP z>OHY>lp=*NFp^2RiN)X+#>ubqYKqjB=gEstDcPko7d%}F7S6?%@Rb@KSsxUoMziv4 znkw&>R>zRmxZx5r#g#fBqQ6&)RU@AR9aSXKk7$*azm2WRR&VBDAuYKiy!{kY7myqh zKbt#4yCJyv`@b;lG%~n)i{EhPYVhRHlt<_6>ea(OiEQrInYBVMQ1J=5?+z88(n%eZWY=E0Ao=AR&Z0|MOE#jwxvIkk6(NA% zIXey+AiGi&4f0EL3TCwaUmNJId*oG@SJ_cm@&-z0@3-=ORemJZ>9kbPT1b)R>r}Z@ zH82icA^?`&Msw&hje`V-Tfb?vPL)`a{lVInGcbEDFIblTLI3AXZ9#?4d@QcF>kH<* z*tX5~rCyd+)w6K>(IXCe04x(YAbPncNhix(9WyaF8imX2{{s`a=j24wdm`Fs7iDP~|9KdCQ8%N%2XLUah1nMn0Ouh>TVo3h_Nm>$ zf;k8X!wm{u+%~8uW7Jm4M6O_9fp@jIW(H1V|4O{f`x00DyBf;(#roB{jIxvpS|rrN@>a< z3`gWrTPR(lzS$t`hxB(z^K?_MW%UGx3zD=lcA>Ed@@G(qf39D>E*h!}Y~h@#(>*c+ z!M8)!!XuJ}mLV|laB)lOEmX64A{H(+@BQT-wh?D+6SkiM=IV;vJgTSQH9l+OvB&Rn z?O5+Z(%KktzxU1=m`wDl%Qk}N4DJD&W(K-Ds|1e&1dzIqd5MRsJ8&L>0x~>-Xf6(q z8Enm)wszhRLiPG+%ODcjg_=~p=dDZ(D^bmjL6_WG<{wmKO|Rtk7c zK!nFm;sN`cU;SQFYAEwx1}A5U-n1;d)|0osmfU5cD)zPzulya_2ZrXuVy3;L_PK}r zEg7w9P{}updBYnI2$1JSn8DXZc!gT*Dm|Z<`w4L#@WrgdAk)3Ew|Ssm7v9&{Cv~|l z_7V=qH+l@0rzA)lM-nH9wW+0B*4elgg`tq8(~q|c|L;&=4gYOK1(~8Ej2u_W*`_gV zp;Yc$mC(^5$`9pqX8h`de1HcDeD6C_kwY}auZM(@Qi04Gciu@dg|g#GeVl{=$I_SM zTMVP;TPZ+vn|XPy*GKgNv20Ox+G!f%Mz`*NRnpXIG3*By8?+o#uCu%6m$Sw4s-KIh z&0(EiX|Um-{Cj=-l-mU9E?(@&Ukl%VqTP_g<{m*}AbbhZT@DejacE-KPsR&iv%R(a2L(r9; z_9skypn`mp748ASnghX{rT=y1g1i>w+5#iws9goBy9__v3Lxw&eh&aQ8iUE4CLANA z6d1!hpi(^Mt;xPpkq`%9j%z94L-G)CP-9~4>-~n;V~nmnBJWu8lWq)gJ~C{+?JQay z=iXH4qPX(THs_5~U-YhTH^T$Iy~_BtZG3uSuJ9xl2(Ac5ApN@49sPsYSA4?ra7x;r#Sb6wuze?^_ocgul$*KdW2wdOWjIJIyL)b*!O=yL^;_@I*>7oEjKYb_C7-B+`F@WF+K-@p+VTg%_1niB;d`*i{iruZxsvnSlmo#OFeM)wZSDec*i>3E^kBJ5!ZR=+a9_c*iOuBY52{>N#!IIrp zWv5%XwHFK9)rb+B7SwbltVcPDt4O+Q5ziC^DEedJR|$vO0VnDiOi%*aDRhXH!F$(K zjX)A{*LD38azfjiK~aDDgkmY+w&#v7{B+GW9B-HWwf49;;|XWhx)?Xe)bH*n(+R3duohUlo zym_}xuf6mzG%70+iIaih){MNog~WbJWq3lY;F1S($US^^4iC;%-nX_i({IGOaH=jZI1Q@La1l#_jzrZ&PT8!&&1amitx3*;6-e!@N4F~RE4ja z=Du-k;Og_}`>m^3P<`kzWQ^&Q5+{vR2w$ohXt+0qiWwjMp2I_;H9}A`C!?b4n2{Hz z`@%q!!hdA04|yR_ z;)9^wg^Oo3Nb(KI+G*p`Lisd&!K9wFtQsHKsRUI~V%GY$i*Po;!G5e2vq~_MhR;7_ zyijB^=D=Y)#qz<083|NhtTfzauIF9&_#)T3#orLz;0j0b3F+}C9}tqhd= zPgtXfetC5E38>XkpUQzIpFBOM?UgJHDa)uYz-1$SxvYNk`?2etvS!;k#=Bnf;FEij zomH=5#Oz!Mmf-@jaB+u%^YbH`)+wL5O;&)Yo!~;9i185onO-YBAg|NcWF_Sw z2|>Ztb~kmGS~mp0RR%mbtxZ>807@;GgH?Xe3(bM+ zM!jN!AQ~uE!}TAMlXN*9%MO6crNbLH9TdfS7@0}>g~hSRYE?1-CCt=DjI||n>8>aG z3te{oek~>Y?F|eJ6$ilBvnQx06OOQ4q9h5tA79OOYxo~AaQ?cV{em4%6I%+XKm51;h5!#wxEur|XQ>yFUa%m|qe z+y}Lhd+hM@Z5OV*CAG2$WhN3t7>}JkSk1wSF)>tuB+Op@|G;EL`wbuYi?cOq4NR{G zub0X(%P#gM2hyz=-q3$WLetWHgdZMRao5HBze6tCul^A=e=fje@70q8{B(g%VB0Mthi{Sk5oJlnZj3#c;v z?F$>pwPYp$0I$(t%=hX7HlOVxHp`OXi&*HEfc&L3zeASWeJ(z$=LgNP?r*S0l0n8) zFf*#Hd*E=Dn9n@$=+(z&2w_;@7!X`6V0q!+=_9)hT8;^@Y@Yw<(vZW8Vg5z%_Z?0%uEKh*xc){N2B+c1kgx@Vq?s>fEoWNP>^k7zDF%}#PIQ}a*E*1Gkg2TZmU87rqe*r4k-3yzBASKgkVuVmG$(rbX3*p z^=YgaZLKk??V_G*Z`*DlOwcpp=5fwRuzPej5>v|bPWGKoy@17ZoXserPzJD4q%{qf z=LOq1pIT=BR;@rg@!&=I^mn%yKZ@;Q2Uxe~D;v;zT|D{a;=bJ4j}9nuTo2#TUGp#l zQTR-7XCEc3%D%spNGE&P)&(a>X01EyoJE1!JlEN99U#?vt1Xjiarj1`W#OQR!*qD2 z7HZu8=gWPAD+u`@C$njEECQr#PmiWRvGS)kAO1OQn>{i~Z+_|?gnR2>`lL>MTHN=< zvR5(t961zw&TYaeTq`Tfk*T3KpIkEnc$uSFnX@Ft{E%=VRc*59{6Y_d&tm{0*Xw8b001oGV8tE)b$9mss0P5)-qLX(gf<2) z9QroD^8wJ_*1mq}lmqunfm+Va8EEvy2!-+lpn@`T5LbffeE$!tnVxVY=ymD9Dgdj9 zn+vzA0!N;%_RV@vubBJI6H%xmbNZ*{poa%FboWYZY{v3?**O;Ew-lwmCIRoO0K!5T z4LreKXc`=yeFq>)rD-Yu2l`-0XA|{60eC=L4!CALz(|Xse&l)}JDyv!`UtN14Ms0* z(4%|29)hs7Ul}{<{C9EXH>i8^;PBzU)z2;cCKl zx6NeSlbm?SwV`%zP0BY|0e1A>WCT$}6%`BK%ARrJ!KCAaWO@D+gI_&Y#l*}}=>j%R_esUuTYlF*^iGg%5LVwU6F$f6!C>opPXuHL;!YLb;!9vqd?ZiBK+;DsY?51+skp&y>b1=njzLOovJcqvH<-yswFEM1O&Xq_U>6V=+M1t}f6W$O% za0P_EQyA1F?ECM`j=0k}IZR;JsKX>6yKEHWZ}kc1MXyoQ4Gj6p+ih*Afcxit70%zho@6;u?2Tts3z}Emfb7TvqT!-&O65M%ZLp_T4 zd>y7@XKAvt@#}|*(X{x3^0Hq^vF|Cmabhk6q&moR6f!jv?7#kUzHoViA{cx&k-zk( z9fy|`(k=Te!QKa`@5d zKYQ8f%b+*#-JsM@ejJLJhrywppVmGr5ywI5P^3LVod?Iv2%Mq4svDPZ%$f%!ybgj6 za6XqAv zC1O;$G40pqy~mk=L5Z#$(}zmS5?>yQ?PCJ1wM1US&))<4(nBw1GJK{s$aI3T27z$B zZ7~9Qx-uFwaAb~Uo7U8M^+=qkC$p}a$YZHlPTEZ~F(_ZG5ZU|qQNZx3H`QxYp&E^% zY6g^HYp(TN;f{Vgow}aT`%Sd$6rOb_r5QjShBL0-Xfv?G5je|yan@RWZlXax{Pn|e zUpGUF5;aEptPKT&50K6|FXT#R$9m2e(b~K*@+o$psQY4=ChDR80ewZ1Fg61L4q2up z##*k}dF@E(wex=70oJVjIMAOAt(q2YD|?28&d0MYk`&1nsN1?7sqEKT2?5%rXs(sq z2A_0~?WQ^t+m*-KxA1O8U89WLpUZP8rkCy>-T|^Sx8B~T``z(N%%tc3*Da&Pm^jlN zA690~yNOb-*mCB^P*5cB2$U3lAmTlLFg9Pj1eGu>yNFIg1t*S!wQezVAts_Hen4Z% zsSpAthkeCChG|~ksoTS@9}nXX>AW}M-?+?b?{U?-HKPRi@>$NBhu+%B4on^{i#?t#74N(}8G*Dn?|Db}6RL zU~%R5jYN+vy;mcD9wqnRuMxx+;So~^bst?k^1WrRXsnmFDCtm_U6xdMm-l?vEJ+dH zR+}?C7r6x%fa_uaNg*5FZFIhN7oT-@cZTeqo;GrjY{w^3630^#gE%7z&1NCh5y^ej zM9|+oX{DK{jSU^NFlPIlbP1DGO7+#ypc37*;x6sK9d#b6P26uGyuEE3YW(^nm~Sl2 zw^8AktB$k=_+KJ^SQX~}F(OI*nJYqUzN`(GAfZy4F+#tevcRoUqjSn_qWl_YKc??= z+c+TjFVYOE1xXw6`s^*4w8MQE?&|E?()qBZdr2xC9cBf~HYbZmj^^29Bqfe6fZh?nNEgR&bNNQ25-GJtwHn=yvT(yJ>vjD$sNILpZ_Ia znSL+#mKGM!x}C64Z9#q#LgJW9x~3hUuMcXxvrrF;a2DP7(HBm18GmV8FeA2Cp2$Fz z!yT1Q|5YF2iM%+!!%IQQpu5wn)Yn%#6sF4Nv0!b0B$%}GH1+cVolA#OpT>H{eLXkw znPBCxBF(1n^1B}62Pc6*2wxcOfiwH7&jxH#=&FmUxA=g)qA;|HrOS$-a;lI2 z-Qe2SK4RBmDyH2=TrpNWza(k=yxhtpHdpA~Y(cM25*S}X*@&dk8*mlou~W66+V}cg zntgqP(Nh!rLoo())@{r-# ziI@FRNB>h`CNyzM;A9}zJDrl&M-?|(=ECBI2o+gO$*=oo7s zY*0GA`=&i*DYS6m@(Kd}_b|M~sNRbpJ*GDu3=o{4bN{`!G3A0pGFAScfwk~RYyFr3 zbTQW0JQY~~$`^r`Y~N)lZ(m}wMz_V3(tDdZMg8uq3!3|%)C6ohZT^1gBEPX|+K-X! z|F8j&&jH`4e4jQ=3w_7$cw^x=HTM|6J-1HF#QE;uZCRkIm)i+(uCkve0NF9?3*Z{s zwx9zlR4_f@zck|i6p0;1v0!q|DOjJMFj*|}k<Clm^u!pj!F*yw<<=4H7%Z+*|vB&mCTDZPOS5^Ywn+$@ zJpEbh96bB8<&E$Nb(UTfig6C}3ft}@#|W!Y4za~5S#r#na@J0?L;KXHyz^5UW7hbV zqD&)bbaz{Ui|f~0vHz!?9TIC}*K2?02ckDgM9v-ChOBIsCZFB-%DQ!#}L9OsLW71^_Rrl~HPa(@*`~RVUMyjyDFv#&uzcqizIo>Azv$ zjpxCd2s-b2l7^PBnS~{gbz#;EGwo6BGui!jxTN_4V`wLL5bSJ}tE;|s; zEIk5qKG+X!tG(=X_zAK}!vuP+$-{R8-$R~JNZ$p#Yvb>2qJw*@fCKQOX#&R-9qW3k zb3WuRm?NW4K~pC1>mQULG$M`DZ35(Hd$)5Lxc90U!NuQZBuJxi!I*sVu1$dw5nC)9 zHy)7{^E$nw_BE707T!T0P1V}byF15>r^C|E$c!LjP=L#0vHug^`+-)YUMK1#b!(coVkV@7%x%1e5e67;Ag`X_vyN7+luzJUx-Y+G4dWY z`9h)Zi{kLtHp|IUT76L|Kd(n?ZQpE@vpUC)kiAWsbkfGUTy#)WbEz?NT}25-WvER% zML36VkEYV&=do4Q-c`xzcXW#aJ;(d=dd%z^BjM9fCQ-pP)oKhE9gZfwd{w^KWfQ;a z_(=Qx8f&RzlJ1O%wlxCbqG=_qg-sFiw<`%lbw|VZL5S?0m}oPoZvSivvD!z>KeNh`KP5y2Ar+)1BUTS9~`j z6CPfiEW>1}C3;?Z>8X%`jsA`<4ltg1<w}E8uKgT@@VtSKCN9vIr$p=Vb5ZCXq{0pB4&iw&tR zm|pyU0sO}smEM9CmN#v!W;Pt&Ra-oI=a|pHwQ|NcGP){mx*C} zmsVBmqa-@piS20X-+-f+yec2rIkwqY?uUbA;X#c*8BX4*nVG(l(S=#{9YF<71zzKm zp)_55{EDb58oPIk(+ct>)CeT_ktnRc4Lb|S!}(r!*dE@Jct{`y{+`0UCUXfsHF|D+ z`j40jZL6}qSgG|s*7<+klEtXB7MsB?X?1eblB%2}VL;cKjbR@`Bi(a9WEw13rayQN zjlWUrF4oP*xnS<@slQJl*2GSS{$Spx@!KygF-Lm@rmp=>=ER{2yoWNH1c$Lw*rCmx>+NVIkck2PAv!?)r+?U0B;qlVheRNB%Qjeqfg-RTT4!kGbNOY=^7T9{7jyWIu{=iM;5zRk@6H zC6c#4^XDBtXNpgAVdXI&t=eApD~qa_|1349s~7824A~!dwOgo8jA^o0$S)Y&3|x5i zSfM!MlVyhtI2?UWJH`_5MX_A%q;VgntdqN$_Z6uE`&H=EK@UTjkY6tmDUmi}m%gV= z``{=LO*N$dgmiKrhe4QE&%!hd;d~~;iuZFiJrpSjOR71f5MXDf4>pP{)jX;My@)-w zCmbUnry)H7Pf$U*TeAi44ILEl@+|X$qKkZ=tZUs-=8LU>*r^0OMD!Xq^_qeMQ#3d; zWXU(Ib*sG7l}*gRNNX3M@fvKXA6YTp%J@NoVgKXjA1-PUR|z*z?P2Ox_hC z9xJB%t;MVmC50nH7yH0cZ%2=m%9wE|a|^mMpO{ zW)ZpR-oyBjJMlly{Wluc{B^8|yaD18kqR1rzLCkkubBZwSN>eYdZ315qh z(x5_GHvY!*O||2Qx>IFrR9l`D;x$D>%8{#*ZYde7pmO{86`ys~+__Vg)AQoHtk|ix z_5M8hf79W0$DVBio@a3m6WOuf#E-MP}W{1mxFO$1ZPMzL(%!elx zj62is|5_^WuyC!3S#$K>ZYD-j?En1P8&;hCse1O|Gq8?Uzh<*x*NQs4H|ln^y~4jQ z2CAX&E$rJ7h!3MmdJ}9_(M<-4>4_2oJwv^IQDgA%2wGUkj3km!P&;uDA{@WB^(-E) z4AGNylF$$M7@4aH>&D~l4~hawFM!cO59Q>|P8*L@b(p0qgbX;~3}p|hv^%WGS?d}B zsAsX_Dy#vuCviP#~)r4BYL-zc)&B=jpHq?ce5JZR``n|jSF_$!+6tOsmeLC@Nj)Kv0?a#5-R z;G*;5x@;TA-_XeTD6C*r9siE`Y!1giZf=;Bkg!hqCorKi9%6PSqrPuC*Ld2{swIi5b zmK1*G!p4%!{X)!_U5+fevzBh9Pc!Q-DE~jgxYm>~_5SnI+ryMpddH$BNvV^%OV{(> zeax2Ls`X>`>wD&9k!+E-U6{8%5Nn{l7$xOTmcQTt-pDYnz zWv*3vz#L0$11-gx@ojE?#7ugopg3vO1;QLPnC8~%moD5oWH0$oHat_IwM%E|pUki~ zW-3=hE5c>wr{3dB`ZSn|>z)f&Y4eE-M|(l6!l;3MW-h0vodxUub9E7Ko^mk`EvI$I zp|@jdM1ut`3 z=85Nc&-T8LbmiBRnEBq%lbiSr zdM|8quwjLo%3W!_rGfQvH*NMQdtFVto=#<}+~v-pCt*doF}g$c5XLm>l)xrOl(yti z{YS|6W3snd7_N!D^kT0PzrVroW>2e;k=6Xj0HPDR+(G6wNT97DUx8IZ zCzL#|ZPnbS%^ZIJlACy7BRd=~1O(n_p+0c3^^m+@igp-$UH==YlA${phWU4?BZt2R zQA-G!G{NqxT*cp_XhAZmqShy1I%VnjqMqI}_WVQVV3jkklN^v;cV{fuUGhk~@&fw}#xS$&bb>PH} z>f+oZk~s}^xPSsZ1fY9f(;Rgf4{J-Gh^2oB5@GNR4T%CG2l@o$tb_A8to%*^*!}Y& zLcnPxs8TwZ$dCU(-VD%uFlBIbf}`BR^rplUnrq*K)piU0s^5{x0z7v&0Rr%1)QbTe z?xHyc(88}`!(Dr;6sDkgq6U;YRcSHZ_j$^>fA^!%RV}i;27m1|$uUF^o37Y$=9b)0 z|M*et$eT;M=Hv*z(X1*!yJ-ImVpMv;vJd~wA11GqyB7jqZWM7ArSsHu&)i5lGB5V@ zLso9A>M4Dx=D8|iBZ5~+)S~* z;gXYkTdX|1$V))&XVy zl`|zBqRgwkAfX%Nlp#T)LXo1v-6siG8ViODqbXSP9ICBkv2KZSfj)XBD<)eY1X z&9BYS4L4iGsBsEx9p!-7JTmE}VQyFJsfw%EiSnYz8ul-TNF(J z4yxn49w%o-XB}T#nyOgsa)}FB($iZfW-z5qj_N@+EeIUJ^t1o5Y1#fO`U8U$JcB%} z|IO@L@4goGnfvwc*ZXy$_PPIvY#3n#uCcd+a)>7)57lK8 zycw2FRZw!u&>J#SM?iut*)G`~LmyP*s-;i(rg+Ym6Ae75200GpyGCi-=C3WRk|?P~ z*4{QZbn^fK9ryG%GHPCqRn9-~1NiE39WVfTG+a|x+;Hr6B@u~M66YC3xGoX5a01QB zKG=7Fc(&@Y-zbTV_}+*&`AF{r<4C~m*9$i53Ag=F?6NE+uDb60Zes5UiRD&%bJVQA z`re8B6;bg@bYC#I0}iix2yqZH37Zu#Z0zJQ)ekLa#+8%XnQaGB)+BJ7qz~?a`+!^Nh{g~^<#XDl$TEHx=23zbm;G*2Gi{X5YBzuu5<3_}vcIM(~vBk&q zo1kp2@M@BpFMw*rA#G1l8xH$jy3I3(CKKkG|<8K zI(kXx8h@((XD|NuYM`zO|M4T6%#z7G-Yo!osxNgQRhdQ(N30Uvqk6z$$yF=W#|5r* zWpcyQ@z0CEmHOS)9of&x!+XakpM}(R6nDFhLn%W^tE)AQXZlFdrYeCyCL5C`nM{Wc z(RK(e`jCP@D?J=CS8bLIO4C)HMBU{f!I&q7o~!e^Q~hD&~;OFVCmR^?U*;3ee}qc}S?UNZu5n~VSDGC|{w z-Q<$`1Skm&c9TqLOr%6`-$_Vq|s%O7J|;+B^EIJO8bYFx;>bzgFvQ zQKv`aA}ZPOFYY`dVv!9h?<>F9On>vtHX?R!H(O_mkc&cz$m)u+8OiUhPiSJZ?^4gG zWGr0Ht^+@F>^{g#6b9}&+S*?`d!>Ir2Vaw{zt~RSSE&+>ahVL{S(dpwI8j?)(&$m9-+zNFWtU-brUcT zqp6irEG_{0DrrU(;3lU0Il}@yfd@-K4C3OF zT0-ek@pFNHWj$)RwHh9ylS&*rb*R)kfhz#~q!O@{Gd4g|@n^J+c3@Cf{}W9uxbBlq zIbK9S>2swSrSsWGb^md51Z4^^x;a7+!#3;Lp;E{@<3{toB(N*#N$H_y>A2=Z9?B-^ z*EyI?#PvV1EEg3eJ|&`Gi;Y$7}&U8a4J zYO!{<#nUIzo+_0-iI?yP38sj+24v$9f#GP|)>CMSkaSTm{t9QjE4Gsth>~L%^K$&% z;*P4{b+sfc4xgjH*|4y*@yI1%E(#E)Q?DQQyEhK4m(~#h%w^j$;8^#87u(n5gf|x9>Ebn|T%UOM zn&U&t3|jNEDjs}O${F0VIx8qqMg0Xd=Aj>VQb-&1-&Cc|T|*V$O)Qy||u7X-bk9{`vd4%tb%4t&{&UrCnEcLOhb zxI(vhHnMhiTaj;=MVykx1TCp0w^>}%ly6fk#gO#P6Q0bEv1bBlIjK-Q6o2)-ghdre zDO^n<#HY(D!<7p+7NwUV5~e!XX1V%KYKv~O0ya4(dw+zF8V?8keqc#Ev|P|v`yeM+ zB0bP%hUF3&U^fr|JX>BIe}2P3q<}ORvKq%k`-QHRKFKf2Fc_n_@zp?UCnTXw>%+Wa z7|C_!Gxqi7YbO#NwngX*TxPaPEH*86@RvQRqVa<*Nm%>~)i{Bz26KIh=O2ER9g|E{%uLdFJ+$cP>Jj9!p?L zc0PtpnLmmldN)4mj{VoxgcC0>xk3eYnLR!ax(+xS?Y<>l?mo^{^0Jx(z?{*|)d-=` z&9}k6v3&cUw9vhdOjEm)fmz;5WAwXgvt&$iK{^I^IdnUs=|Wr8ptXtZATR*fF@Jhu@JpxO%l(T7e!W*6 zG(x)#=vdMe%%J~&>~#8*P6m(>*aA>6xWKZ%6Z-0%BdcwpcyI(8nbSX}w*pS1I3JsJ z$reEWCllK%jwJg}x+zx;cyXr_OcP@?-E*PQ2=U%bN|g$;OTn3D`$TYO=lIHan5hq; zi>1bIyx7{)NY}Wm)ADNPO5)PJ-;Q0(`%+vbl4$THCSKDOKF0#2joWfbNV@0|U(^6k zZ$|D(8Emy$=`Ob1jeJ*|vW9c>r9#eVb18YvixJ(7m==`HPR@Ebaj9LeSy%}reRfEi z1HQEU0)Ik+VSB(oTFMCuw+E^y(CV~khIlL>X`LY2hDnj%Kb8U40Yvo&2{^?WNi_`^ z?2&XGwWmsm&FOPQ!N{RVMst6uZgon&&7Qavb3Htk^>NNxs@XW6s+rGMYH z;@(^s8}i7eh{-L-i7Vk(6^ad@HN;WZ^m{maS7)!lpMm|CBfoeKKo85#8Z;1JJ|^*4I`9lRqrZL@9(ex|n2t7IKi_1&eul?ob>`i$k&E z?(VL|-Q9{qad&quS~R#8ElzQFcSyeUy}vtizccycWHOn|$+OSd`&oOfwY6dH13Do) zPN)we`3nE*FT&|tZ0PI%dlfOr7hmy|gt=L6{t(E z%k0o~zz3Fx6@vpzT>dEH2&AOv=V`P4SH+%*A7iCnzr)0}cPpqbJaxNXU%E|kyR&2h z<59{o9Nl|aM*#pEudK2`=3%J_JJ1Z0tGwvy74zwpsM@LRyG1H=_^L;+1hqc8^dEyr^#g1aR5}R& z@U*n|4z^sHegYMaP{{QUw74zFRMI>^>rT(I3FVU~Vf&%P$?m~^3Nz=wt!~W6@D%b| z5zvE2wG$i(W{ZR_D(3P53EcrNzy!y4dnkC75h6F!XZc;;v53_C%jz13|NH5m-vGSI zWAbMI;@}_v(OtmfL(c&Oszs>sM0K5Jg+XQQi7%f(1dxE%UgMRZ_2brFgV6Iw-rwMN z8$iY|fR=$rd+cEZ{ zRO)704!pS{{h96p-lsTOZ9$u46vJwtq19?aVz(jU{ugMiIqNuzA7-*vHFfw5mKcZ% z!(}g-G366rs6Xq;VjGf}#L@M>3P@Y+ZtmLFp6Ab-d3ed95{<`zvi z3$PO$l!~^SX@qK11mb3F3^q0Mm79l5L54@yj`{I1)!GneQ~%r57VB3&t9OzNR`ny; zoRf=p8u$REO*%sg0LCzk;pZE7dUnI*AEs55fhyA5I#TfX%p0FvA-@CZsD7~g7`VUS z2A%fbTjS~_)_qzxO9VgzGxzD;)d|>evoo#A7_q$R_zWn-5<7hM+0OOp1td64ZgEaQ z?R+(I16ud{10c{IVewo!+b8OU{>azabMd~|X5Ap62R!`x!@@KHKVx%%Esir3rJF7y zxk)&;=lGbTL9Kc5P)eJr`Ro_ur#qqY9sU(;*17KzIYf`W0GHPY@ff2&&!8H$vl#fn z?U;SDqpdqwY3{#~ul^m?568lV&0KuY&+?8g7F!_j&dd*PYk$f#pLDhnGT&><9u_lg z_y!yr_sX;aS{A5ppINJtd6i8rf4k0qkpZR{?pPCG{psuxj~IM^3tb@l&@o~5rcI&R zKdVi|G!A~GXtRP!wB5Si#m&R%H^<9+PFqkfwd-*&+h;{!4-77alH_xfJ#={jB%Gn!>-WeLzc@9pGY-X$sDtO5)$?;V6f z`-6AO z1D(hPg~snrtkV=`VA5IUNFW-E-K1STwGtnPo?rO$K*BLsvzJKK&vE*mIw_u)*SmuV z0gq54={wMCkhp;<5XpA^e?`>)=q{Ts?slEh4$*B3)-fwtO^D42{z=ku>P_77-4T5Z3;|D`1u@o*X{G z7QX_^v^cvBJ2`ss*AY~T)Yg)~`#2oY%szcwIu2bK@ZiQtm4>F7w=WHrB2R&D+4Lh*}iLUIs_%}5;3Z@3s$CWSy{$7}_*L{(jX z0w$5MEfll z^JhkG-#xMx*!hq#e-=~c%YOQMf(PIjM+ixzEcu3#gI|9v^aRWwQA)Yj)0$o4sQSE_$_R*>#ID8nn4=|tdj$Zj>rNu zBd>;d%-=)_!dM&eBg!r%y)^;k!+H~Q5@|m#+(eaLhpvnecQt-#ee56iS_eCaD3g+S z9JK_w-H#*c(p0(+_!w_6Hk(}{_fvay+)nX~{NdtL2|uuf6@GZ!HO}wjjzA?~;uKG49EicX9Kl7}Llzp8@*)_wn#$+=lqbRnRCkfq2Sz=@^ z;F)`5EB{I=(GabUgnv23%_Wb7VUcr9P*`JpPCZD@^r%WWlMYy5&592jx&t&ch5bTT z79INdHUCe^s}OCKGqzt@I@7w=UQs3gl5SLn$&KxJ(nX0HE2;xeHxg&&!o}wO7Hg$$ zt9yHPMQ-Kh^h!%QH!hsj%F+?O^0~<5_nfWAaaFAZRs5YazUj|fOT{sX9nJPzgwXJp zA;T13iHHoe7<`n%>_hJ0A78c`7n5<5X})nPIjr4^A(g9*>DvQkB4oLF@n11oxqRLq z{!=)rn$&3j-c4TZvblLX?E!H<=hq*CQUdx+wy4E|wuagB%JzgQagU zEw^>!6iUe}_O2pZH-T^^@J33V6sUpI6v!_E8Nwq+4<=A=Z#|f_({#01HZSZxy=s9q z@J!$dLNxG15TvYL63I{e-?I9jorEcLtw=&;L=NaJW^kO52R1RP6b6d>wz@&Gttdv0 zv}%x>GWUTl^*dZem)Bt)FM>@wioA;OdE3)51?)~JT|Mm4BGL0kTYK7fzSqK zP!uU4dYu9~Ey7?IY342rujsiN3O6HGe2f!rirNLe~ck~ z_0>d?GeLHtIf4QnCPThgWsSM$p^N6F_9VI3tS8+_Q>rGOaa3LP0q3I%nP||!ZaoNP z$xf6r#oM$UcD4t-`!#&0?VgTJD*WXA@dTmok+)ivpb0Dj}Xs1r}&L27# z6q?EdhYN0UgVy~OsYiW1On%fQAqRxlZ(TM)l`yUoPC|(PdmAi#rW)kk$$g&9@06><+M-NY?0cnzVY$CT_^jlhw*==4Z(rUrM|A|X=Uvl5XTemyzg}oi_qW+^7%-kX8+p-Keduz`FspvB0r(&$xukNkwH-} zf&AC-u!oYjD}~$MKpy=Fol9{5pX=9FDE)jiVL>v8%Mt(#mEZ9H8rr!5WSutgew6>x7lW z_n~T`ibVP91-kbWOTD^zZ)4Lq4;-^;$hersXk5lhn|jUbjr-0T#&5!tV&6=BplgG? z=&_}gkZuwOTUkVl{%ADn#>O_eXwc`q#v9#5Z6v{@SbVhp)+0_+Qaq#B$#0d_swepA z%G>>(7jDlx!izr_D$F;(wa|0Qc~~**$GMP@>bS$pjJ}@j4p95B(qrzbSz}0o+kRdK zbI2QOilQT=233;gRXnl32Ge~Fo!NHkaQ5}`{0ZJ@r)|-5Z1>k^AtwJq+BYpw2=?8DO?ZFZi;+^DQjgFPe#gKY)qU6y zu8IV%3Pq9B1j{%}nyM5>qPuv+e#5P#Gqj*=uBDs{90C|mv4@q?harWb!Of*)rzjb& zLIOR`(;-Jhckr*ht%|IuJ$osgbTN(Sp6{pCpRzlTf%J8rcwK)IjGMUAmy+Ks`xgZH zUU3Mt;B|b+@X^D8b?5ks z5k~B+Puw*sU?>0XIQkjgdAI@r-e;QCiav?F7V+U5Zr{FvRn0abhb|BlppPk4?0~i3BVLmRgOfF32 z@lZ(l-8fwA2!>#<8^7_TI++$`h@Y9EL!3%L{u%SrT^S|c^+5Pf9fBY1kWAL}n)VCDExuf=$d9`Jm zM`BdDK1Ze#l>&hlPbHsJPmIl)F;+lKOx#B%=-8k3|=ru&spuJ@ijr z_*0SBVz0vk6tC%r@iD@2%|}yZyBYo5isaRwZ+jyHGSJ{z1@|-RC;uy*^MD5*D*8_b zLvQM-n@5LwKTMhi-Y7^;op@ei2fTYNdivHsHthNuh9{Vllz_?y!+spsPaSal@Wt1p zQF%Nj%=*A3#TM96UnxQ+5s6Dn_W)mP_N#aE>|(0E*b_S_#!%dUKyfU7>|!Ol2Cw8M z$PC?v%(J5jL**D=pA?_ee^Sc+Nz+$+>hp`j1|vxqvSN}QriB!|(N}fL23C9#Xk=fh ziwy}JmHte(sU`W^ecd_tN8f~{r$`VIA-eO08*J1jTE^mPx=FVbttQ$#S|G)LzzByq zoD9_0+QYwS_D|h`sot>LhVgGAW$$XX%P6rUtP_{GQt(#utJ4ohE?6VM?i0|1W7{?%&fRA% z(;^##X;mYIcO`LXBfooLQWkJ4s5Qu_$fH}?5FEX6YYbC+qybgEpbhuRa%!z?abtAq zA)KD~tj`Q^??$eG0UZ|G1AykMLDr^#jial7m_VAi0jz{c@Q1+y7V_OZfB(Q~uogHT z-1Z8dE4pOAtu4$)JU1Ic@3r_a&UD6Lt z(~J4ok4kId!0h+cH=J*%8nJvP?_A>erR{C3q%c8j`-D_SroB~owdzhyUidPZ9G&)1 zE%!*1JRxEt8-zdUS#j}_A__n2oM}feJ@$-)7B}c>(r7r?mXP8dq6P8icCwgF* zco39jEH$JmpO#&K4RZxk{3K|jh;yWhb5|a#wzsxBlj{lE^DeqIm!eH#QfhJ6O1dM* zNq=HzqhJV{T;CCXy?pR_M9xY@>ChDsu9~9?fK{ga6yUKwEY_5@+s5_}Y4|rMAs$*p zS)LImgWPY^U_|rs{t2`So&c9Wf%1G)N2+t~(}9k}o>A8kqJKGjgA`BihU_9G8WjKWG) z$G_4W>F)Hm2qveFeJMEFdzfHYcG|j@pld;B{uHO92hX^iO?+fr;f^lh(~F@Q5!`gU`3$ zgMat1D}Knbe56PsL^{_1Qy7v+CTb{B70G&cctsCnU$Uc^0i2N91v?GIJ$jos?kjk@ z8`Az&l8~VY?;ybut&@wE{acwpKkKJT$~TOF ztAty{>Cg@|1y-w1kRjjleP4|s{R3?5l#i69(mB}bG9$8#NjH9!m37YF=m_N8s&|9c zHENw^K{3a_3Y6VDcuW!C(`^^1y7w2~953ukkm^_5o1wewHO3?9`lg-*f}Gtu1X5Hq z6mnVmdn@@IURY5c4D5T`$Tjo|NJ+|epwgupxBe3|iE|=Fh6d%Vv)fgRqR$-_P}rJ>q&K%0}Lg(@VW*j=+G^Ef}uHi#$mDoI~Z||0`6!WieSOr1qk9 zkR578JJ-mqBEBy76iRX4`F^zVY5?{hiD6j|#bY7m7&ATjy|a=cd*S&tpEWU_dHC}J zOgEC`2Z|ws@&uNRfntPJO4rs&{Mxtj435)$`51one)194?4lgG81UQfX@J6vPp z35c@1#%?aunrA;f3b89^Z0h5dKiNBnV)|CxR%Lcrj&k3Ga^CUppS>~)M>2{_wVg;n ze@B{#s$HyyRTO|4A?z+?`vTm(H9---Hc{PDGc})($3AVSaS^2+;(o&(9R4#oA>vM6 zWVbef!l>;Pvhi1`VEbP&`DvgtYj^L|kW*%MT5HGq@#( ztgmbnM{*BrIejw={Jx{}A_+Dv7+ZK*Cr{KS{`*zv{3Skrk3Oz#=f>xqIxjM9+S&)k z^Ht}@dhSXrruvo6@~1ZXhy0jUQ1ABK(Q8bIUc`$M_pfRsjMdp=0@!vTi1*OP44#f2 zPpJkT?@f>(WbOWnWD);gE*`)X^LLhU0gTB`TV>3!$$|C`+iut%JG}kC6TKn|(kZ3Q z-#Zxn%B)uW+XH;_V!6hfsTWfC!aD_WMVLq&N%Qf};`g3Q?BI zq9Z*2*?|`JvdtI2YYbCMWx)c0a)&^@wWz!4RC>jRiS%#IoLKsd&U$1Rz1M7%^ncFx z=$L1Pzo$ECzVhqPDLGU&wY3z#;dM-985D7pYMG9H(DMGY%IpWc+o&4`3 zK$m+#N+zb<7ew4J6Ne`PSt>{y%9>^|KmQICT-V0QVW~kYA6;GYw<%YC#@Z$T7|jSv zm)ehWyVN=sNF6AN{eb(bN5D}Pom9o+Toq-WrlTCsZrXc3Og0wdjpnR zEg${Fu=qE!ns{=h(ktGFF>3M8DT*;I`AGoO7)Mo}0!nIN5qrr8G8qMwarW@^wTr6} zRxu&h((zdhG$FnX*KIAQ*LoZNZk6?me!*9dYE#iSo|f~roIPVnB}WL2p9el^QhBN2PW3o8!;#rKh%fCBaOdI{zN~>UOUZ5 zk1)QDkJ604P=&{=+8B}b_no2RU9TUG91lBtC!gLuY*NtB?XxA0 zn@B$&ZiEUY##fH|ym%tTV%!F1e6;>9YEf@97aE#VK{?uK>s^TGSy_zLaj~AEpm?K6 z+WCuv?&I_kH^3S!b{7hj3!X#+usws&Gi{3kH>W5tSXj|(VDqsqj?)Q+*z^Iqh7|CD zltZsUe@=XQapJGL6bjg${KkXKwjf~wQIzZadjy-0NMK_GbO|F$y>IWqW09ba^FEAS zNFraMtXX^kDqAXi<5uRlmwSs7#v9-~WJ}*-q6>vVH?0wk;zLG!!Tv>MLIHN7Ov4_l z7+x#Gb z!(G167N|9TfVuG9jd`!ui2M_g*CHB=?2g`RLWQDXi{SX^FTos-0pnqQ;`Bcss&+s|Il)KJ-vCkMPF= zc0O15e;MvJrzl!9?Dk3?#Cx03>iw`u3zfXfis+Yue%NbQWlMT#IS$o*1jJjeV81hZ zGivu1&R2V^{|m(H{i+}s5>e@nl3mxCh1}#;f3ClRdbZB+ykuRh{Wn$c<=`Sk^2aY9 z$&-2EiPBj+RSUzISz)F0?lXwQDsaKPJ8s&s>d6m6fQA`~R>N_&|0So0**yl-(1c%kP;iArzjri|3E=j?wH6`V7 z>s;FLKZLS+U-i!@ocF(R1CCNITLY-qpxoOLj}cwIkD+wDHX?p?C7>9P=u6NX0j?wC zrzlgn!lk;nQZXez5Z#N%q-WTb^IZNqBT2S4e!l*bQc&>(wU=1#icoCmUED$Qh4ml(J4z& zs&lIbjGz92drrVd0)m!By$BgIp+F(`iPv~g&>4Ok7^Q604LvY@@D}b_+)JXp$~vP1 zDQUj@*wV*5ate4W`@eH>HOLcr6`^0)Sum6Mr&PZYgjeRfgX%OgS6;^0U>&0u1xR;V zk=7JMD$2rYMZHbp?A@h1%sjGlCZCk9%-8J;|HvyzBWsV$Es9@q2xh8j>Wq2_`HYW! z()L%}OW4c)umlI+k;Xz(&1pxkN>lSkULlceo3f5QZ53l~igeo!HI~6{Oihyu-PZ=< z#-q|3v`-MX2W6*S%U*GD`b&2Ml)USbvPygl(voDl z=(~M&3q(QjPM`mk$1FCr^@;Bn)M58D`o05iiVaQ|^@aj4=gSnX+mA!E>I6EGQ6dPt zfVqXxem^(7T&xn%5BJ4;JsWzT%)YXN@qw=;pM|Np3BU3usiF?XjAr!BSGSl>vjV%R z@0NoL44pHd| z;d@w8maD@_go z7Li{{=WF7T>=3cZS}o#w;bFmcVR z5plT34|YR9f5f02bKA18`1f2o5dFpLG5@>SwEL1AmPW^L_iKX6Ou?MyMjVRnlq3o) zK4cX`%;mqS`c7DXYCH0|-fooo39?LNCdfo?OS$>}AU6;9Xx(Y)d!pox12~9J)hb22 zw8!VOKu!f=A}r*=+@q%0B4krU9hD;saV>;L;0OftVYgE2K8quOM0^NL+NQPc?Fwj zMU^+%RfSG43A1>8PG}qlM^|1ps_F#+f$329z7XNdp#*!*BWSuI?#N-E_a2!wbYa*) z)5-w~HC&a*rh@oYCBhk{)qHK_9oJ|_TS!OBq^f4t%iCYc80!N!vHsNGo&KNc#y%VE z)2mi{x`j?-tpu{DlI3JmQhmN}_pJl(Yr+P-*2HyS z#pVpcVL!8V!S!?=C9FL3{D=S%WexFexbx@ke(*ik4pa{Mfvg1+F-U?GTu}T)qs#*F z-u{dt+c0uO2j(!zh-+W3d5{$ z!rCR+;2?5IF@=DR7TlF8JYo-^OE{ug)Y|u|6~g1~N8jN2`Ad2~!h_7iE4O*AW0R_t zn680pN=uZKB2%PL;Pk#OalkR&ai|;PrdYl<`(|PK;9HBi*XhPhRVrSMYC#R z85PZjvPH+U;3Cp-qm80f%>s>JTS60srVWkR(R{U+SOBNxXx)mm9zpMp>Btm(-my_F zjV@SSb~I1avA&qfT3~(xjF}w3oa{chQ(Oeh?t66MFq)tHG7DCa0KxlnK6n-AOI4@B zPVSvm!Hf60`Vh$(W|}4^Tv9j(rAGelOTORzeS2Ww(!1w8zmBxy`(0#_*F5JW1{nql3dLpGdF~oT1R0si zOTNH3Nm{QWCy5IC*`ZMx$bT-9g>0_YsG)(0g22A*)g44Uu!Aqq?eAAmIU$4|z&&~E zD+WHy7hM0iinPaa(uf#G%<;e>cV=lr)I!{oZisX?`8KkK_-66bXCnjL09VK1aw8;8pC z{a39$ZGaO_C}e6y%>dRZa6hGC6RS9>2$jaqS14-Gltd+o`uOP6wsMqCNQ6dx=ooOg z2+HkEE0Byf82b62a#E&WXLluoEh9NUf0Q^69<-=47YRmM=yJ{}qWLC;{A%}2PzUDp z&3Od{=;9*HD_cHX6|w%7qWpVH2fJAsrPVLw)W3Eu{tkA=-q*J(@H0*V!g&;or;WGB z)!6-{f2?*KfRwxRh1vEADUXAPt>EKCr%#|+Y?HDXvDE?XbW=4Dd0gZl8e?^9m14B) z>saTmYj|Hzyy!f|dvUBJ__4DNmtsbn12V1vKrL66cxIH@NM1xfWP*X#zWf92={6A1B;6Klwo-?5EGs4cN z;<(I|qC!6gyCHN8CPAYKm=6zruBFSaBqmWUiAqC^OKA5SrsHmEaA#$~w1QBdL6@8Z zrH)8O#q7vDz=uJ-J9NVY{mI(LQhT~Q>rl6vkXq>aem5zW1=$$=)|C4EA@)N1R;sGq zkpO>9MZTf%FW0d)nHV0|4GHRL{*P!fN~;I35ny^pJwvZGq7vW?Ea|2Pe8Q0DQD{Pc z>?9^M0_IDQ4a$!so?i|Ab1P3nqwb@`8^tGgonDCnaGXlEltlJEK7cse$R|rcb8G99 zqHt~ZC^#+Mn#iP$TLr!~&-AlOB#n=61A zT`uJzdE#DR0zyB#V0*9buZUa(;jBq=SE=-{)3QtoD+TK^p49CQD;o7lT3L<7&vg2y z5sYw}9lu%hXjs?l42VpTF4{K3P8!SEoo-jQvxd{TAB0fmf|-}x9hS@p%Na@@bgZzF zOJ(WRV5AyPee=~C4@Dv*hO#IQKn(b?BB|pOp^xZBwhPX#BH6adeF+Ks<`JJo(yJEV zSs+4v#^2u(G3O7zIQ$| z(IxIW@m1D=1-n;I%UU5ihiNY-PTzsp?lTSJFBD7lav1m@t68*BP48?BovZX_s}ehZ z@f|z{FaOC!KF{c?H|cEqsit_dN3>n|@}m;E+_ow0f5Pf3f$B)Zy&IUi$zVXPsidTjN)DONDz_p9|P)&2Tf-PsZTSrgfOO^X>((CWk#&ICrxxXv-cYHNU(2`# zH|no#T}h9v!KxRSFBG@NX-K5DLAQ89g>gEXrubi>BIs$ug{_Nsk&0R05Jx}_j@Vggc7Th@zG7qPKvYHfO>V>o#~q`Hr3;!ND_3+J~^aG$dN1KBWDgDN6}4F#t= z&mX-nX@?fpWz~PDdMg3fgw(4swK zvJLbM@VU8p7nhU1YmuiJrBAR-><@ncvZPP!rKdbAr_q!ahr2Ji=a-jNbIZuh@ z6N@k{&mo3a_VuP3(hdI{U1ivWTVq}35jh~5k0mQ?nP5#P6$8?+``O*^l)xe;apJT8 zWw(eUPXbR7&BIl5S(f(I3BRjFrhi(3;oo`;L*@1S2aJZ2&T2N*QNRTIsFgq?*z*uB zOvvBGk_7oca9T?iA>i+HDHJ?+lWQn-R?IBA@zPIe6kE3&G%P*$&!{4=GN(79Ik5A@FTgem!x+V4_8FJk%mj9jBB)b^U`nt~+%PZa{M$cCX*S$Ecxw-x=PvEJygq?{-<7Mz^8jEx< zh08^c?EKOS>iX+$j$$i<-@u>MUAb5@&t~0~j76B} znDjAiEYQ@R;Cs`*+tdmz?TfZpi%@9w?xoD%(aQoY49)6QA6`IRN1+u#Pm?h`uH=hs137hR^qE$@dqd2eCSX+C@4%wrcs}StWyB z_HN0H1|2(q0FF}^-1qT0R)qeKdF8q7!y**!!hAZ}2$+Z_P``Y*b49E*B2!SfqHdbi z1{_rf)sXdv{wAfbv+QEF!(8gWrsC3?_rx*THe{vR> z(-8jY1mgXd(uVXlNn2e{&_J)P&HMYqe7AuXy*KXaclf;EkEvX5R#uZSfH9`@k%m#- zh35ol?u1ppMQH7^H+q2&C-I*e5+d5OC}XXISx6qbT5B6scR;#DX~=fX`-zp+izYrBGxX&GYhL3 zcQ+r&18WR?i|dhX=WCN0G*rEFnxiR&m|;(SW*DL(NWEvOZJlD)3AsAIrA^tD?3viw|(VD(^&p&<(0QTP;d$Ugqmd?P#!#AYneV6M6 zriupE#}5Px5naXMTLskK8bb~<)6S|oRB1xzP8L^HwO+_s@<44;XZuH~gqW+2%JdP! zD2oFy9VU7;c<)}A{dq9~x-N`HcF+;T`_dS-wN%ZT+LDCAFxO2pv^2A3<43^7uHt60;pjS&Dg9 z6=yTF9MiYh=$uEP(J4Sn6sk?QUlmUE z#;o17&NXF&p+f~C6`p4j^8j>(uG}_cFRrC1rrfJAs9@v_HqkLXDi8TYoZ2o%j12Wm zoG`unj*NC)A>OaO&l0iv<6F@XuWT8Uj^#n(ywXhGvd5Q4>qpQZP#}ch>gZf&Qd_;Y zuw?qFELk;{E`9|%yHEE5y~d14iptx8NKRovBC&B5C9dLi(L)Wm{Ba(pedf34mYBzY`+chBH8DJ@W|K z3;6P`bRRR^m`_~;+Kqz}K!1FVTZOJwT)X?A%$Bz4Mt;B9X>5H0t~(R`0t~!+q6*eM z0Ra>L2>yN2*nB4_zoEvJj8MEGZNOiAM-T*j{qR3_QY2XT17DlE(A>LDmZpb+@?hZo zD`=^G`t=@t%ovbpzHKC{o&=`z;Q0JI6(x!gXKnk74)xA~sI4=s*5i zDiS>yS#Ht>_99>BWupIcx#!B>t*64Xv#Im2F$_FyBK*|pm7^CMw9}ho(jXK#WJ1zT*+MUMI zj_b3&=D$1ZDNM3~n1tF@XN7VJp`-u?q4IYA)kGGXdtXbZ@`RM3>~kFL8X5_9QUdOb z%v8dHrfD2~2aviIFOVIJ8TTf8A_`!}-{741y{l+Xp<{B8P@2(F8MPEBCV5lZoOK9Efx zJFX|i@9Dv-82|QF7+LylRWvXe$uC?v{QbG{%lcXR^V0pd<@@G4Ei*LC(}7Y3^Yn(C zqI%+MX}kO-v<}d}Mi=ANHyu2~*A7s5Yv}r~(t3h)nH*0o--V0UBY3>G_ku(ycywzAqt(x-Pf1aJDNXWD+r0_N!RA`>bV#9fx$}jUq9z@* z3_YvvKCAAn8${Q)NWnZEY3P1SSr4j@@1$OZwo!^Kt^;wmOi=1&Hm4Eh>j~R^G-VpZ zDo(B2(#yJAFs@(5E$n`OovAKWt?9&L`<*#><32Y08Lo7M-|H}zj|NfjbNhK_|2G0R zwN!3dK#7BcuZ8zVbtY}~B|=Ee08J^9b?`r}3B>=rSpd~1cX*CqI9JL*jB$f3t=iIG z54L|vOzSN64qjD7x?ksdBsS?)Hk>>c?=Up3@`ZGN9*6eG9!&ICEfP?}L8m{ZPm87VVP4iyuwF9Sb^ThSw=9T$Uw%?F&9MY4!A~me+oy&mS>v zsxoG(TgEYPh-N9Qsf`dEyWNF-iGQL~(K<>5l+_fFsJB!*Bn+`AgxI7mCmONi+4Q{l>!xYZ5x@_OKloNNGh{pa0){+eqsM1q;+)v>MBX?&ZQQB|(k5`Y(s@_5c z=!R6JM*BbfR%X2M9Ea6{7U;2TBXEMcW{n|{9_T#h2W4%tY&N0&Okl2Up2H52{ zUf*UP|Bf5hwT+Om#CaXC%c|&3@g)JO<;uduv6iLng~Y`JQUau;ukyGV3xqDg*1vaL zAaX3?`_u7m{nXRJ2>S68#)<6uA%>sQWQCU8=FJfq=&3H9OBLE^w0>!&0+=ChBMy?6 z$vYA>kPx*>y%Q>%p8L9Q$#C)YiJG2a(e;(A@h-Bf$5E{H$c2RCyl3Rs;Z_;+3n^bX zcfqC8-k-kSQQYHP!*Djirs|pFzYOUf>xK%vu;=B=_X!B-it)XWYk}CnTW|UKewRNj z-ndZ^Sozhv-C>gm$&J*SydK$rX6LVJKwQUZ=GAG--C9(Vf1`pi8R9GplZ z@m;N~@81ay!E7eIY_G9p3V32K%!hktADjtVW$skcb!uA#|1p6|2OVM}1)dDUmV+Zs z_-jj5{bNpnkK!m6;D=@56!6^)jQ13vXB6Hr35v-as&@0piyl@m^D4;RKjy*~FtG|T zR|BkyS&F>OJv&ZN#h5quG?AyF$HH+N9C1`Vutz(Aec|w5?r;mP$T;UOPXa zzt0+`F84*(-n43CEu+nMhRmDNfeikpR8pFYxx4tq`k&Lf(E+_Hi8J*2TT*SGJ8!>3 zV@9ibtr`gk{09Y#TK|^bB-nbmoAtNdAK85J_X7z|G4Sl|yvcYa7<5*QC$~+HJ=Tw} zuq-KUccsUA(V?&s6N3YRo4#cG7_;br-%^m3Lwy{(N(|8PFbScZ_uHI`zvo`l+L&$t{ z`zm@yRdW!YUFeFhVb#xnEHIN@`1JD^fi*b#Cm(@XnC-1cag2!PM<>Yo#6jiL{>yE4 zWYBy+lM^2^ukxmaHd=^G0o;HMXqu+j&+5MfEL8cyPkVTAFNjrE04ii*n2jHk^bM= z&mSg+qoiAaIC4H*-j5hJr!%c#*p_P5wh6%fvx z%!GJJv;35^M-X?C2nG-fv63i2aOQ}3giFCtG#YpfY!WqU&7XLfLk1Z|fVK+qx1XwStlB zI4NH%=L$)gUPiA^NsB_-JRl+&(LBO5JVR*9)4DI|TOh z*nd;~BL8e53_TBWhkFPf6AcEy!_$XoOGC!q++|0B_VR;qI&v1=dGS}|Z3TE;`UCTv zGa91eTG*0fG$DfdREy$M(0&A?F1nHSq?wK1$0A8?RlJ&%p)&6D2qWE7IXq}k>9{My z%!C@bMUcp3;htC9zTfX&pil!wDZby57Qp`gLEGl*%DpU)mjx zQ{pSyAUSG8B$T@k6>{thMM{DN9i&@J|LsNu=}(`a{~v?Bm6J0irdYf^KRoh8zpku# zZJvduf;ocsj`_l1iuI?P?C=} zjZH*(*l5wMmX3X=C@0XTU*TH=sb@ybr>Y}L31tSu7BSSbz_+Tw!L)a7HfJnKN_2-) zYzvx+wfbrO$N#Uo_kfCO_uEGI(2FP?6a+*N1gX+NiVYAD1nFJrAiYQ(niLTb5a}Qw zU5bMADoBwI0@8c$y$sBJ8PDkk zu6m2Z6k#q?{}`E;@?djeCq@ERBIUl#lZK$B0}Sa!R}6tc7uTw*dk4QdUm7VS37YI! zk>5O~DKZSmeGz@H#_iihHVMVzqgxBCc{Dm_Ay+yM7nUN$OOX2XYN*83dy1(y>ErM| ztPIaY6(_CJ%9n5vkeLM8n)+1gA*UrK6iFf^c+*Rp#&%6eXqE@X+Kg8iD!wULZzK-X zx<>3j4D<~Du~UMoIu$b+;tw$+ntb_M>{Y;bqxbNml)aG8oNXrdJY^)_(3sYawhU(G=eY2slHAOoEBJMG<{@0+rr36L zanxArMQmS!!r+Dg^C1C(*V`*+X{C>wLgu^t=(n1rMN1ZaMG;5%@{ZL);!Gcpk=4&H zO$;T*jX-qO?&{#&B+94g7ZG5=enhL&FXqLPsPCShS(A&KyYZh^V=WW9N-H6-;74k18L4}~ zYd4ju&v^rE$`(CFf=Zu&=1?oKL}2SagP6F{{i`2tZ+U7z#aEwmkR-Y!em23)6?GMA zn>=L0+P3e-7QZx1%qBb$^T?-St48rH_K_Dji4n#ol_K+}0~cUr!}?Y`PYK&UjUo@W zN-jv5PuC&^DUM=QeQo1URdYR))| zY$Pk{wSJv1$(F`@SbpJsGPyf_$US@S=6b*D-5W=#cRi(_$jwxq)Lyqgk(=Su&RjJQ zru|XDK=^BUBG2S<|DC2==E{~9?B3&)`H4hFiu7{Wx$ya2@>kpzx-5ZphZCnS2ePs5 zvohfr><_CJXk~9dp}`z@x;@#R%=R4>&v?gl%YC*^4EbaEDdS$Qe4LTt6FQ>uVj zJ;Up=9Pgu^W1Z_ESIhah7hXh9Wqy1>-2P+EQP*u)=CGE3z+>IH{=MEL9G+;d6w^48 z6280t5)Q2f@gNl}5fXG&li%)RkSCsQ2%RpVsE9^u6TygK2$o{{`|H97BWAvdtmQ_p z%DrP+b!hR9-%^Qp`+XgKnaGE2+}8KfG*j)%3!a__#V14iombX;Pd}6!(PoEzLmX}x z4U99bZ1OQ*3wXvFW7k;Yp4g71sEtAPoE0>Z2gXFaV?;VQ(a0uxJhtk1`3TwmUw zG*FA-mOGuFqa+AuWIf)$Kib}G|7g#eScPLiB!*4FPd-`Xij<47ouP3=->Eg&E4n-O zEU7hE{W0Ep$J5)gm>ZkY>xcj+m3GXsS{H0343nw%>11~fGnd0!AirjAPFnDciHmM>2bB*6EdN(dTI@F9BdM(?d(l5;hvS;Fg&tP11SWxCEvcX z+by=sU~qD0;b8IA(ok28)uZ84{27J9#i)0TEqN`TE7VIk;iGWe)sqf28UgU#gPs4E z9nah0D#o<#WLxEM`~h5(!0;vZM-hQlYhowOFWFNM;PQ!gC+MpiXASUMrcTN}&+oS? z^A0YHh>TMmeN!b!2HJvz|9n~*BbwBm8 zD|nfq_9c1zXFXY(mr5{gfLVB=*L`B7WF1bc+r%V(z@=TYw{e;AbO|#JoTOkI1|qv! zq0#M68=CjO@lpt;3PNdIm*eN=mulo*eYvb6DEEE47Uf>+4Rh~SS1^O{*5?tQ!Kct% z{F8~`(8w+iq{f6dth`R$$R6TGbu4&asXM8&29E_?hXUSpDu;@<@K-!bbMPaO?x)?+ z#9Rx{A>JbmGYk*(%KTBWQUBGC6}Z> zG&8+n;*+{cK2~0BfjjFZ?&s=r&wOy(H0C`*ri+D1!%yfQQ{r*%&Y{brk+xQl@yEh>(}z0A|d^{~`0sni2`pUpAyYSgZX z^d8UA=j&6*%irXP!g#&x5^&6MN%yB3Kj!Gwxb6Om*_3Qh)1#FWZL>&oakaSiZM2fw zGWNEYj$zf&$6VX?^b^ifVhxkQVhhBjzyFr5vuYbZ8J>uM?(uww231`#9XG>t>MO zfjC*Jg4@>HPzid|w9!usZ?@3*7U{}3bbh>519ES0TPg763hVjPdu8?AN z?ojUWR~s_%D$nHYZHY&&mvejKR5uQ3c{NZW=tMp5tjar$)#Y_i=!%3>Cyl(**DXZF z+j(XQMTgjtn6nTzR7v*dU#TJ4qhxxbAIgwuRl`rOTYV$Lx8TIBtNTtp`gw^plOWR9 zK@XL3a_OXOwOWDBz0;PBc->Y@1tE>6grcW>M%eNsbZaE`oxk!%ihwRJ#Z6?Jd zPS0MJ#j7cVju+qb{rtUqpVCI=7Wh_OAYjGnA!_v9yC~~s`TJ^!6M@gJ^VDmb$bfWv zI&IU&ZsB{3IWG8FzmPOW7wB-+iJ5y*v0%5o?l~_SKVRlr?a!xtP{UF8p8KN!1=KE) z7qKW$7wF5cxt)Ez2Hd=46AK1>-;U@$msk?w(I zwf^<_(bM2oeV_G`0{#}y?d{`PU4+N+Ts2il6inim=8{g z96dswsfBX}1}0BAV&!vHFE>T=sJ_k_(;dB*EB7LJeEos8&XQ3fdAOo)wVGDfjKu5Z zy8N}zn5J@HS4w@-hrI-I`*b8I24~iM#74U}JxDOqz|t>YPxa)bX{yn%m6}2bgmSJ3 zX~-9E&)l{3ubdP?T8`9Tw6-oYqF8s!|8UvaRR`QSzETO{=NIQB2VhuZMP2p&EDEuU z9C?<1WP;fqcws^ka#{d3mYr*9FuZGoLPF*Gmlv7%XO6S>n|OYFb#1c`b3mOypm_=?ZQ2yYZQ` zx9|#$k;33T99r3+U5b*&mX|0;sb#5gJ-_Bet!vdk4)O?jxx6Gdk~Y`TtNkV*(yv|@ zK^1DK%d4HN(n~6DwJes-dKfy=CGS!asM-7Nl0kfzYo#^zv=T~*J&e4%XE|O}Fg@q3 zTL5ePD>0?LLZvGf^ZnChY8V7m<;0n*vMvg~?m1MZ_9}6{U!_l!T$}h`zgYWpI>8uw zN4@WtXt1Ud8LHY~{N>eFmXWu3#;0QJ3RfRsGbylOezVog+qiR&I;_S*iQ-j$NoR(X zdifZv{^kMF2ApoimQW!0l9i(TAmO-F|8~zPRsE-|pXtkY7_JjhDL?1-$r@eV?VPGd zZ|Syo|8suA!CCOS)@JBdW*+C^VpVQrhPv*!c{$n7UDBYcNbfmQC;L0scjNEvd_Nkx zHd-DHqvk4H%+lnEeAX5uqj={Cy@TzwYwVS_;%OWH`5OfzflVEUk2%*GS2+8lbn$P! zVGv)LWN}x%j%YTaJQf{^3d|m-Eb{PU4HEx0zGchjGjsd-1)eWhrs7EEGCR7$EDqoxPli7e(&HnX1q8Qo}_kdEdV3CkbP7em~*gOkURYv3Sy% zU8^F^Xq~>CU;Zg~q76dk)os$)GeSr2>4;x-Oq48q1lOa4EQ~G)KdRQh?Vt5iI3>vT z$Kv?+*!MM!N<~Sqt{cRU zcO@dGwzSr>s`A6nK4hJONdV~D1f_Z$F@E&ebhF14fH@ao zzJ9Dk;oFM%ZDF(}3;1`FavRtaFupkS80GD{Uo~kJ{t!poGtziuLmwMm6K5 z=qrO!+?%-=W8XhJMI?j=eE#Yir7p!!p}A;!Q(ZiB_od!)tA@}wcgA9@i&G(qlP?~U zz+Nluh-j3Pg@~8!jt|tG=;$t*8V*6T@|PE868<*>9;T(syrM(r(91&91$fm2V{y!N%;xQm)VF*u_&% zQ~VW&hAx9dYF=HfskQ`mkSn^0512gBo^ltscfze@IqezjmJte#BnMY{it!96A! z&vsk&AAH}d%kXUWyEd6s!|Nw+DY)F9H?jt(_u0;UD*8nfz_T%Q>HhGSEyf07pGqUO z!OVMt+cwqX14F&K=*4$i$`KXuvqwTR#hSX06db-E9UbpZWVg_~+uh^@Us4)dNWJw= z)ARsm*1OB3>h?zN@QjDP>9skoxl}GVRg$#^-K7Brv^(` zF(c}M*4tnyUFcD^DOM{zh75Eu%I|EHEjm&>@$`X4pQwfN6J;#tE7Dszex+7TrIiiE z*9!R4ygW7Q9j+M2Kadpl-?C%9i3CeA3wJu)t;8w2_zT>Ey~r6P+`#gftui;f`;nh`L4J zG|N%rAl{P_`N+Bf~{ur;t&BF5Q4-D%aFLt6W$u^r8(_!6s{-r0KEUa-r(Ir<&b(-=b`$eucIsnyaDy%^Yaz}^uCZ5`@;OS6ZqGp& zW&Qb*5CxnoO#tdf=#SkYCrru$i&Svc&ye1Hr?b$7B-~?Uq9g)E3q40pf5fyKn4R?& z(M$3+-4Z5;^@w-{VS(4l7(-GC{eQ1<`fKYsiwI8kK9{?p(jw#04=cTGx8BPUMO{9i zV>xP=ndNegWoGxg!@97p$%?sfub@Ocd<{(4khC!mW<->GhebDt*>67 zq)|tH7x3*mR_@Ry4CoQKpV=>9RfTYGaNr-(PZe~G^`sv2vsyX)+}76K!1Zgzdk zN$lUT*bvV;##5QqE}=px?I>0m%S}%AEaNtZjE{07znaNz?~B1w8jkhvMrnp~{Vwv@ zdP8;=VQN-l-69VJ?0in^;yc95(j`CDErxtzcoFK{ApU?uGFadpK3!q!yzT2Z+RNU; zL%Ft_Hy`Bgk`N7=$LlO_~F#CtrD2v{eLWCt2%DB5QFK6>l`FnW z8a8BgWoeyFub5{pE}IXKL+a6y+8cgAGG8WWoRMVOGk?(2W{c%W_S5j5XU)E@(VTb^Mq;c9-2*U(GSqV>Woq?Mj?p%Bk_<`qS7_ zI?Eq=q21n8 zl&qZNVD$65%=mrUS>L1`w1bzI(?Uh3NXhofbNY=pG_-lj@2X6(IlZY-Tq>{cobs@A z#6m`AEEwP%P6CM|OJNeBPI`HzIEpv7L|Dn@J*kSkY2lB~)+CdyC-w0zI}1O-&=luq z;62@VuK}w$Jh8)cIx4K(z^+L7Y5V&X-_u+8xG)lK8E&%p;%+}(_ic}DBw~N}6g*$; zBH?vN{iApNoO!R?hPr8{B&n%c1IVI@w^L?5hHnWXdZatuFPqGE(3#`yUwC`1v zv-UWxkd=h!nf`-BsCTXMmvU-)0u>+Wx?6z@=mGYnQnvprs^jGdGZx#@oP%%>7gXcO zNG|{SZZp0Zp0V7i_kgi8HLBCE^<>U+oUJh~vQXHt?iuVsM|jdb24TG+*zJ%S$WZT* zNUa;{hUbU-+uttrs#I#tY7&X9;EPmte#luSS8oW=&FI|!#Xj8Wva2KXPKSJwY79P^ z?=vVgOuPWES{;e55qR)<-mh}Z>&ru(DU0tgstZjI8|f5!X=JJMp0p+riV#Gy<0nIh-urd%_+za%-8K|uFN5hnKe@G!k$Ku65b5=%Bkfhy)1ds=4k`74UFGmB zry{+(n?fohhs`J_$+S&%`F@qef@I3F==MX!Z>Nmv$#@SR#Zpth_@ z31@Ves;~c*!1w0ente*Ql5GTQWJIq0j}U!gYlpP8+OF4`E>sa@$l5YwIOr;PKiozQ z_h8lSduTT=U$`>yxh18&4?c1q2XU4NX`^!9VCL1s4i^W!?{LjM1BcRoyWw9=XC#@h zfW*Vq?1H*~p$AW1uw-KN;K|w}dHZ9JH)WpcSDGy6`M~z_5rh^0r0#pA1`OvP+Y&$9 z+4yPk>Ctp1gH1f zDgW9&F)k9YZJTO0k@8{oB;H8yh7o}Ci?1ipdIXZh4;H7q-a0xwgNvDnjIvx7w)#6u48)kI8_@*sAV!|;XP`W3e=`}OGozS*5w2~Oht!LC*vC_bNa1zl6G1Bf{0jf zgmD6c@EQ1aWoopocAu!BNKD)`rkkSKy3mdjYerG~jMjC^kuJ_J5(lN8l4WZ&yaX1- zLcOqLQuGMUI@$Ui_HAki)+6aFd6wjWLdKYV3Xb{ze95V{jbFDq+_!!bF`;a}m-VNh*I?ZBSO<|S){lGe{ zNxs(->jd?}d!0P?(NPlhp+2Lr%!G5%PB)i6u|5@l6IY7uHIf+B!MXdC_G=kkg|ENM==7nq38 zST;|Ef4@F?Y8M;i$5^}gYc|O-hpAIO-?Xge%Rcyqf-$2W?9{gQiowUoeyjBaw~iS3 z=u9T?^T4;~e&NA~DQ#U%R#C|G0OQmpd zS`RByMqnsWD?TB-B=Z_Yseph;B-7VB3t0pFLi@R8DxbFmL?X!JLGb zl&3HIWgut$_2EeQ)~cCg$F>5|s%9ODmbzOdP?JsRs3dfw{nlqm!n24Mr6bRb1>23m zdlC$Dsd%ctXHNA|$Tvk8IcHz7kp8x!Q^j#lrklD}-N_b>#nZ!YZDPOu!gS`)OO|C~ zL{r9fIP(Ey=3Y*x7~fThlkQ2A zmD%T}F$#F4d0d-IZ)cj;p%InGLdLo<)-FmbN$p!xJlA8zI_cFJxlvZ6jFY*p>a-+t z*)~%tTosSgsZXEqYSP_pwM0Hy`!aKxyzr;*P3F?Vk7J7i??35!!&qATC`abW>+IBm zlJ_rayAsyIGc2iiI^W|px6wZ1B;cVJ-|jY%!`_Ei)bs3qWw3E~U<+ZyowQ&WzUJ~N zFto#eK9s{dY7_W01{va&h5e$_uXkJorftU&R~Lq~0Yw}%6Lq+N_hYp|H4 zIHm%Zn{~!at881(5S6~gH+i*HZ%FX#DpD_>}Ao>tM& ztKI!*u%duSn1nMgB20cb4a1y-)hD;AR6V$Jr2FE<9|&G)FYx6k?Jeh~D90;HKWW2l zK4#OW`S`AAi+TTIRTsZ_SS!sY4=YaHa>W!i+x}`hd-l5K#(8qG#cZ}u6$P!qUvb>oP-4E|h%rg#A z`fHBh=B+J6ZOhk}HFjEI$lblV@RXLi%Q3vU1!|dkN(5iUxdvPP#Y^wK_VQ^(;?!Nk za*ZL6*1F=Ac(5u96lgBUg&~RkrNd{VJ$5#`I$F zUdpDpI$t#3S1-i}m#{D@r^udPFc6QkYtjH(1h zlV{0yQLHBX-r5@JGW_<6XUYX$p~?-Lo=O2~NfGrd_=Mw+X`-JMlZ2(&x_xfwjl{uW zVm?hy2;9abIpHJwU2y7#-XVW9YxVBTjz9P?6C68Jf9)0=i+R4V*a#KE)x+qO)Twl& zj>$pFu5Df%$4X5-K4m0X4}Q&Sg{-qC4S{mTUiOt6J7G1hI!X?Ul7@*5Q!U$qFzgNx41S7z?o~U9vIbkCY)vezo)4wePT->=MiCabTe?pwqp!H9ylN(KrbEn( zeYu`Jee@bj0!6fWJ<|qCmPkL~#@Ic20^`&N6LhLl$^3ubm-D|0C1x5VxgN1$QzA(A zPR6x}84iK+*5 z8Cf0a-GlYpM5HZ6j20aqjo%JhAAGKM96z}KEYh>^QJXM3wI*KY+L0Vxh`|VNYtt{V zMi87ZBkox+5R@6vAgz{r)r4m+t9Fy)NYUcWz8T`Fs%;CwUM>aC;CmM-gF8K;g6Z!n zO=30JoU;jylEW>g2V41F!mqaeItqNkLVg85$BL@n;xz0C^DX`6)z-*%;!%qCZ8>FA z@}(b)#@_pR$;K=+eGgrRe1AQTFZp4%B$GattEZoWZCfH)%w7DPMN+Id_?T>s-FJt< z`NF%6yx_~94R#c-NLe+LpHY!0UYY0dII2d5KvFl3jd4XrWJyO) zGnX3DZtHo1oye%)+Zzrnt{)A~W zdzk)5kp+%fDcJ%J{+g*&afi*Ku4Z_HJH<#;g%P(A`ODtc80&{w36+fdJ!?HeWx=%N zmMkVgmIUzkb^39x$*of^>+X%)IJ0=`@7a_6O_xN9ta}8b27LIjB;JoLxCt7XP+SkC zw!SVMHP;Y1_$4g{d9r}rrue5~WL7zj^Q@N^Dkb-GHipNPvr+t)1Q-uGKp(0sh! z_>8a8DSJRti)4>E#%UJ6F^7x*G+@N<1+=OMA))#GJFByW)2Ar<<7dU-(@ zckjNq?!9#11NZpuHN>^k+r-;tuxU0Nq37D7;Kw>Y+y*uyN=izE@OF=PBZX$jyKeKR z?Jw^f*%v8uX7Z>EEbdN!R;SJjN+{ExKCO0xAj|OX#taCWln7j08uNzfBI)w)dt>jL z#-tlSRE=xLN>CZem`(#@(b%ZnAcLxi7-0{oj3b zddD@l58(t$ZZXtUr_#-P2QxRh47a>CG2H1cqB?fUNlX^>x6VFXNhw1OwicP&Ak{xu zX}T5mqY#D2#)k}}Z9BxW%B4qF_Az}EymE;KXQPI42|ZWq`us68k(=wRMDnQhBQUmX zge9x}o*lfZsF4}8tBBo>l_Mh^i*42!qUYjVF)20MY7uR{l(@uX14PD%8fC;MpsBGK$aG3?LeBD@mEykF~{VRS;~-pYmPX7bN1~3 zu)4 zv(D^|&3rP~COK2!v+;!zSFXp5O93Pq<}3MqmNT!Pw@Y`iC#4G}l4B-FBd2VJ8k<>@ z*2(ARZ)Z04`Z;xeXTE>;r*jPFu-8|iRSw^w5w#10w73sp8n3+FoT{AjE_&0t;}1Rw zkQozt)>4>HEwh;RV5FPd!ien+HqMEE!=_2fAZ<6|mBp=sB78{ZriCAk|G@g$AFqEhD8S`yC25>`J817-o6LPMlZJadfQRv%szytZi7|Rfx*=Ee z1$uupFuI7VSPVT8D;pBzcSt%zNsy&j+c;UM4(dzj^k83B)dCav++r0H5=&rV)@i8m zS^*XH_ILPeMECbvxt+9ko%qHA7lf`bX)vl(hWd#PzfVgwj-A-r3K@9v6vj2VBdfoF z$?GJ}abj{bbCX_}{BUQ^w^g*}^LNF?`l>K!dJut1b?l&G*xx@>@hqwd-tj1Pb@=81 z-!4bUvrj*m9!ex}cRd6fC|cNs1A(FZ)8sylB15{86#bC;Hl)n8l}T4rY9$ zgKdb(wx}7Z-tD|j*nD!hQ@i-Z+?eQj8_j1e3a@AJ+*rnfJq-F8pUhZilC$9RX9Jpb zW0qXZPj>I7`F4ERTxH}DTf{Jz-Rx%h`6=1H*Mz`SGE}sDBRPR|7iJ;xCVid;b?l=3 zouKhv#*W{Ik*D3}B^oE^{9rfnwt@YP$CQP`stsCDM-exXz`;YvSwpknUKQ zU~WIeQ#oRbn{xo;m75)Lcj%qq+wdATOTYMG-`c9d^r{5Ha}L|VXnHwOWZs6erF6Q_yHbezz^grQP{J+c@tRv# zX334p*8u-y0(d zrS1VH{XF5+MG*C5dO3!k=>R0g6#^;FShJJ#PV8}I-p8&+97?kE33yuzpMvR7y!Qec z0lqUnRTtJP*Qf*ww};kuhfoqIJLF^;0$#|;6|OZF-GM-AeFi6b-kp@ZHc`jH1lNQI z_KCAhB{J2w3u$Nw@$zy{o_up)_|a0_yOH;qWR0*^?>KzS{!}_q8=mnM_j=1XC8jsK z%!Mf-x&wqyzO+LR6mRe-G@nU7Zo2>V2_Z2G^eQYuQBWA5(l^r3~7hK&17%jCzcx_GD+PUfB4@ z&t2I^F|)(cjy=i;RHJQjbIaG_E$Jzq!uA=YT1C7e2wP}tYh$QNJ5N+Gu#ReWud#8w z38I#2WpZ(GamOmQg(+U%I^I9tnVw#?g9r{?Q$;s6pv_N$*m&7lO|`docQ-aR4x~H3 z1qPs~F7o)$aoh@mW^wZ5Xf%&JCogXL#WtB8WiF|QKq+ty(g?nQFz41YKJQ=?QesbUP~&h=lgh5rA)8K5QwjD+%k6=GvL!b(KN6}wM8ySq|+WYC8E@ogYovqZ47 zo9|L3IvPUT*bnjeAL53_Jh8ITv&bt_(OCZ&z z-&b;&8Clu?Edz2kH!}WXN1JNEZ6jl7ZDeJP4udoH!ra;fSje4U{1$pX*-S=%`OzBL z8>7RZe`x;;PWDy|f7x*o9UPybkJ-z1aCG=nAm}i$hR)AB%tZ1Nr z|67Q^=yU(nC=_t<0;%_4Mi3Ed8oF!>%umBoQV19^`2Ge$|6$f|-;FbXK{#fsv+a!a z;0j_}LkC-6qGLdV+QIR+qJj0dTMJA}qV41${eK|4#S6{r@L@{67u< zM}Pm5-2b=!|0Q3npzc2ZUBmvWVCZ^|u4v~?V262taNs%rpC73EB!z&F1x!1@G_Y#` z5CV__PyntDpcLF$cEIfl>_GrWz#al%0qkc0-$8UEz^w!9!vN91E&;A-0QO~IzX^cm zfXv_WdVt?9aHD0<18e~M8vu|gEL~t40$cz*kc&wV?6|=G5#SKm^#K@weGdR!j|q6# zC;+-q4g}^?0FVdhGr%^$6hILGhyzCkm|ejCHZVH@egL~Uz%2lvAE*`NgBh3;0FglU zUBGh!JCOf7Y#ML}05AeK&>LC?2HNi!z!zWuxL*K&O#m|BZULqMFqeSo=-~L=2H4R* z;16*_5LqY$QGx3&gSLg%0D|a1D}D{6?K;pqGte^&kiic0!Kns8TzR1J18L!dLy%w` z1PO!k7TJU#37{(}knejt5cB|WGWQ@zz72vDf&Gap1gSKECLgrV>IewZ5{4i>U_N^Y zLC-0N7Uo)V-Dipl)0U>Hso3_rYsDknjFiV89OaFa+ce z%RtaLD96btP-Vj)XqF3t7H@#6_6UMjSHM9AZy{(i8iMd|{$F(580dodJz)6Dg8u(q z34Y%-|KR|8-T^_9|JC{5b=))m>A3My|ND;n(|rho`2W~(JJ>i`pN~ofxQ+hPapTGV z-Ek8;{MB*)3HyJe)5atIyVJJ#SEsG@!j=zIsNcW;WE%tBwSqnD@Biinj2(~)&7xx;T%tNj=y-0zjTgAYvfN_(aqYQbb?#< zAD;Xik5duo#W}x@eJp9#&bNlZU0FR(>Wg9 z{QXHMxTXF{56e0J<~g429M67^zjco1ILC9Ivj@JZs^gRaZ z2L{u>h7#a@hS2pI)By|>Ft-5q02%;#0YG>1+YP#o-`(jjFb4q8W);A90JI<4{RN;I z0NoAu0igL7fcC%bKY&>a@DpGRpbMZM04)OyL!cpmw!iHaz#IqY0YJx74A2eG3eW_A zmj4-G9iS3m5da-`BLF&mXg@T+1TY9t2T%?0Cm-nR(Q(cKfH+`8z(l9B03ZvX6aXC$ zI^IbD^!4cUqtjdtfKDqqd_4e~%lX@mj(;Bjofh=<==jha+C-=2698KNGywW~w44qA zH2)0%9S1sHXnE+gp=Hzqpml`S2RcvaazO*FXS99`0nmE<1%Q@S4}jJm+KvWV=jgc5 zdPL_NogcI=(OeAxS}vMD1OVy&_mGFo1w3VGfUe*<1YOC4psSV;ME@9q7-hha2bARv zaR_4bhoD8^q7Q3_(Kg!ElEag6@EFl>~9!HGrV|fe`dC4Ged%AV}d0 z1S!3NAmtSZdJ3*rXNMpSkS@Jj5M%)I`W(28Rw2j~$S?=xYI7M3cQ7HyVGay;$iZ-j z27=s=z;K5Og5aZIG`9#r{y?{ZK@b%D6l@7%2SIOl!Km;P81m#o(0h>AIG~S&I}nrv z(vxBahCD!T>A;-}?)gt3?gF6m&y1it1HFC)hX6?^CZPz3N(l%G2#CEA2$0ZBLO?)N zNJ?Q4??DG5Dk)EM0GCdIk!Th-ls3~<){xVxUTUz67tgIZY>}_oDB;Tm*+u|Z(IHBanF@>}!!QCW;le^OoeR}rTC{rRf{q|X-y zw&2dz&>5(0B{SC9Y>AYBu+9!c@dyaY@B9B0bnGdgP2!#({+J)T9M;9mn0|~p=}Xce z13{=?yB&_?MEW3iHTjYA1VD-oVxQBZT!jkf{EC!s>9A5xdAPlddJSg>Aq=QpG*|oc3A(P76O4KDw7q0bLekU#tQu-mi?{&sQ$+aO$eO_LC~4t zoI?CfLsKv^0y@wCmj2hp8BIv}v~1ZjYXVDZ{F`zs(q&|s&4=I8|GHTJ-y`wA#}Q-? zAb~4t{D-vtmD+~>C}m^y`y2>g#S6%L2WuE_`-`j;rc{zwXle+UAy zZ$og>{tXXatzL4fK{wK5Uz%RqZH>K&{jL{tm$x36#!#=cHHyF8X500j>KfxAd)i(v z@;p2kdiJDhb}jqz_nqp!j{NAN^;wPZB_j9qlgt>-G1vutWN?8Ti(ALf>;Iu-=?PuxA& zca8m*3@LNWz3xX7kHqe5SSxRujkpRdWVzNQs-LGi9@#iYh}Ki-$}e5+&6r|--nv5k z(KjFQ-rhUEZp?CdCVD+r%I$rUacTSG1y1+;7+LX5Yx7ITTyy_$_2j->w;bwl@7aaX z$_wNA0s9P0;KRMmyDTPbUp&+DhX;lC=KsEgtY3WjmBOg&g522G{m2WvSKro!)W;ha zcqMf~XT0C|(UO6?FCSd-Ha@&rb?{6mW^}o-nd<7U#qI~{=)2zven->ExMUZLtbFu#rLo2 zGY9VRs23WZ1#v!mD;Itd-Q2FpHm}=A+W#i`o?zi_)h;)pFrowd;GU)9$gU9Omx0yE zFBa?VyIAIJw(eNs4*rLH>ahFQDmRT8?ZH#68ShUd#AeRkQra9H6+eyhGP?)756)iR zuX`c=Q(pd9_k`j7&x#4KP0b!AM}1G&n%uGoJ+JP#Ov(6DAaRT8>shm|$dKvLJ)H9k zhcat|+9BUQS!ql?*mBRZq}{xJ8+6hvOmIcKQaSM2q#yBc=T^rbN4ulUH6J`o-s~Cc z-qfq%ofYW7>;9JJn|)!$pvI3lH8ihF_3Wqhj*aW3dWQ!wyR{4urfj!2$}XSCPW9n} z>z3s%+v59mnfEtF9ya@hJzo+wP!4c-!>{YFFF*Qy;@j=~6n^|d!eOSbsnjtFS$WSZ zYG$%zdF!g?A0PGC2l(&iz3y+EU=x|DRfhxZ(hj;SUf9*#an=2fne`@vDdgN%X5f0p zPUM^+eBkV1x=mW!!K-R%I+OPv=X!?yg8G>dJqyF96z0^!nzzquZ{zntTh2sV?mYD~ zBCM9?f8}_hqI$(*%byXOT<3R8h4SrU>&E*kNNK+3phw*9O=q9mkCe+udxiD-lGzpQ zxFYCN(muW^dt#5Z-saw_q|k#q^ve9tuI!AuxYz@KWZ{CM`qGChXG{RE>U4$eS!6%a zHkT8jc1?Q?X2*gIP+vRr`p>O)bU$sIWfmOu9A$hsdSwNyID4Ww=&IbT!MPGuEC$nZ zWaGZ}33u&%(}59;fHT_>`)(-t8C*<m5c3@Fx`+cQ}xNwyxiZ{RdFwd$yJ#$YXoPK1BdC`iiOR_dQO++F1nc6)VX@G;Y#smt(;r++SNLC{r7_dyjDkPj`=XNp4K=Rh`%Ro zwbx{?xW=2o&97=ER9w5IGNf=%>SZaKejX9=D31K#ecCms7V{yr@2$|wEH-J-$>37B za|Gwjo@(}&Fxs-)X7n?C{B`HIKRXbrYxZMp$npG>mzS0I^T}F24Uf}Mb#>X=c{b5UemU2 zJ`x*sZd{YXUNP4dO<(wu*6H;FdnnYdq8d3CfADa}-t55lfgw~H<6dm|n;UmVO{M2= zT!UHt?pU6k@5Z+WUfxx)cD@js*|WREkLE^WMBB!0EB|aBPk2AEqi@OU#UHz$?5$f1 zQL?`F;YVv#+2%J;eE)Fdy&DB+!@2h6C420@CtSGI*=t=Ddp6$?e*x=4rR}2*lVcAM6^y%W2jArncsiJQdnyAO)qojUYs+WkO*|Gb}X&Ql(^&GK{jp%c;cX`s+b@}Rt_2Y{S=9I(JE0+Xc zD-8@*Jd)O{g3GRoE^29dyjt-f@tOMUY@eTbodHX`ev%ST+aQs3l3o;^_ON2zt-!#( zyCYxRCQV|0;=!Z?604#DvjN-K`cZ0Cd0r3q!-pMDkAs&CljAH_>x7NiL~0+|l5~Zx z3S!szuVqN(b}xp`M|}2f-cs>1JARYo@}>BS>K;@DfONgnaJh&&yMOh!u8{j{on-f} z{5fyroBuzI-e30y^&dWljLOMA+bo9ODgJd;1|YEa>&$ndc(#iMudv(>cjyeXF5(w1 zSD!7JGj;Y*b-G&k>_K|M(V87|=ox{J(n7(lZdmhV%Rp3vdepZizmj_GI{SZAZtl3D=qwyrWL-XIkc^vV2Bh+Izb!kG zzRI>{SQ_5G)SzDP#HtxHR>=J94TBfqao+aHU#o$^+U>i#*#|3Tyx`|wnhHyPg>@c6 zah7UFWam}{VArIk&rMTeT{0GB>TiYxxvM$4G{(P*(R*vTsQT^0jMU6z%;)ga%jHd? zj&~yX=!&urV6|Hq z@*Y?bk;WD8u2ZajR~>X=1NWCEc;Myp#$6h+Skp1%Q+wQIV zy6=2`tuH3%dT+xs`u%+6ZTD2=etg|P&tIqcjPPM`B3Hpa@fxd|UG@`v_XW&5N2&3y zU>^9YurY+;zj?bsR{3k^*2i+Zx;s&tU5CM(IH`NJnf_05(k)EHv(-nneVRwjgR2b0 zaS}({GUoZMvyXwk{Xv6GuaG0m^0n_jC4b47xx)UQgn4%EOGZ$dht-btdB>uv)b=lS zNPEaW5m;xIPrgCDCg*Uw0~5I+4u@ZV{YW~ZI5b{VWwfi*PsjBUzrZf?`)gsL(p7-h@b(o=&$A^u)jYEfgPZ)jv zWo;V2@qhxQAhScB?+(YbhWX=)I?b)uEKOHlvy{7Je^Ab<2G><}60caknU2*3{!Tj_ zw-lpg<9_@d>*&Y2$(*RwhHsBFRP7bwU8d~R&(__9q}4fd*0w$9w39P@doV<9`Tnp@ zmCYzeE3Fv3=eYleDU2c5GUl~J$z4n!Y26c%+kjC&8m+cjD(;zIJ_8G6o1lj zD{Xd{eVGoJ6yLrm{G=prmYi1aF1>36?;F>bn zzF{hXv2`*8y#C|qH?l~=ydLS=9c6*EvT#CuC{l}1(>##H|zIi+XlHc^;H4Om9V?Iz@>G)ke(E0j`< z^pt?&@c?r`&8zWX=K^!aZDBz}<44F0XU&JLZ)-unR$s5|1Z!)n-p*Ry+7Fw{D+})j z6omd-$oW+{RdYRuIG=#IJ} zAf|%$?c|vVjdaUZnX6jz!|u3Fx*8>H&nmMIx3zIZPdHny9iOdrNa!&XCO*yOxP|pp z*WPsU^^5P?z@hq0S{Xh+{MD#v8N-~?7iy|{2Xp#+{^P% zw&FyM1#9~JQHSt`>FW$J)1k62c%yIqEk~uGby^IQMU*w`Us3apXi+_kp6n?Y_0~8W z8rreG;)Yg7O!xa33!lgxtGA85(aVXDu6yF&<8}7>mh2aseA4bB3pb9A-jOEK-t64= zu$VR_D#|Z)HU`rW=-vDG{|z=m@c-KA;4;1#S9KbiL|F}?kN$uLe0`k4HQBVzc5KhU zF*)(YHK)Ac#$N}eU!RqK10ee1G}GZHZPA&9J1uV4=Hy zdX;{B3#Gl`^uX)k;rfFSMH>A@3sJQ-XX0YUPkm>-us;cb;6p-oT0Dv5Jfi&Fc>lx; z|FKD4Mv2zZGti6wvL`uy<&KF@$L4NA2TgVsBqhELDZJRU{GC)Z{Cx9aMTGUa1GDSV z&1sXjuE6{q+?V!S?8&{oKkhX*yp)2%KmW4!$6m+Xz6QeGkzV}i8;6Zj+J}}4?;qx6 zcL?^k+>f&k!YUlpqmkbEwwpN2U;vtk=<#leug0OqA9B@ur+wQ+i%kqVeyv*=4TVS& z9bhd{tO9oQmjf4Ho{CUYPuhLGsHpJhrHfn2UeZ^6i`}w{`A@X;Z>aW9r1W>_^8$l? z82KNO&piuY0uc}=&wqOT519eRFI|jtcPhf>o}K8SssA!q_;PSza$zB3;s1Z^Qa?XG z1qd8s+Y?aUr9708U&HS2T8!M$z^lF>DGG4$-T7=!Wz3IF`Ik?Vu7!O4DnS=ok4Vx# z1X^yz4r#aNAl4M7&3XwaF6nQVPqf_A{`StOW$kOB+G)%tPUfqHuyn_ z-J@;(>Jbrts>CL+*#07Jpv1F_#44zf%Ph=iieH6I>19~ zo^Q5ow3?=r2Js&`l)i}C`_XdS!vbn1*8a(hm?Mv0N1;N%91OL(GdWWAeQURid?m9r zc?Fca{(bP>mHpeW&F1SbQK?k=ZpOCe-3j4cxrnf}AsZa-ejl8>gwhZMy%Nm{9AJ@~ zqcDqIvj|E0*=dVU1A@BzYqbmcL&}7aUT1EKX60GakQ6@4(7Fh-9^r!MF4sWvI6Dix zReHZY?yF?iUU0W(;Wmqkjw0P6#q*sKkLWZ0aCoNINL^hgh!@J&1z1I2q&=gL-ubol zX==KrYM`T2){W5;GmnvmLTV{RQ&%;|fD*Z(#Ws1cu#+`{PH^1iYV54`bob)Xs0DorPk-`W~@qy>D1w-g#xQ;ajoyV}n*GGJK&2!~tY~R;P zlKPwh96!=(th=hW|6H_w%J~%GS}SKf7;1Kd#KU=*-`VEN?u93f>8G~Vzk6AE>CH?s zNPX2-x7vu4Pu$fnWGEBlSL}WnK8=%Rkk|MuhC}_kHIX{FH(4CSj0+kEpbL8fi~64* z$V?Gy<$u|x@2{2b*Rv;m+>*WLI5#KdZHR$d(6266oP}3k-WX;2EENyt&^^&rdPX1nE09n4Z55}3|p#5 zsr*;vaPxCsheVe1%Tn|EZ`ckDe}Hw}jKy88+H`6N(Z0|A!3BXi<)gukhYP6p%X1V{ zBg1!?(r8RF5>BlYBMsDCyCQo=*Sp^KOBzNra>BqHEE{-}pJqT{(vj^Ed!)-vAe}^3 zUdZFwdRybk#8|VIN50=8zrJ2yAN^u59F?Z$fXd40@1!U|fD`teyPmH#%g^o#b3mXm zC=vC}h06y&y&u$9%@G`PijUg%^d?ka;vk0ekl`Rf2LbA2x8plILQ!spIwDk|1m<<0 z>WXzQS+;BJ?YqfFRW-d$*S-#MXxTXeRbF=(if@eQBp^5%Dm;R9Be`01Hg50%?ZD*g z!mYMCf`&+ad;4AE=Xm~1qPz+Kh>13~HfVmgi#1<%u;XG?*oo&;LsVcHBq4)jdfmJ+ zi&98-LN)xlV3fN%LM!H`-s;`2T@uhlG!aT6!GtL2Ex;7BeyazrtI^8^1Tpe(J}H0^ zpjGM1N-j-d)YjJi{C@gn@Xpz998MRnASyC5$~dLQj%mAC_xREf28eLRrHA49F)AmySh#<@?hB8FWYrrchOb3=S4^)3=Z%>Jm1p= z#|y=Q;iz;MeBmvj2$f)URwNoYWS_NW@uj<_!&lbADO6wQ3N}k(PUC|Fgfom9siz}* zs@*myo-=|E@C1OVo6Dos;n|xC$0Ba5f8=<@$$Xu&mbnPQAs{er{Vvvxh27y+h1o6y zgr}jN;W8?gj*j+H$+iEr+)G8Ck0g!X|Na#fNm~BQXF9oUT zS(SJpu;=%W(Xaq1%Y6Rp(&g6I)`hiEXsnFFeFuKNdG+h{Jqo{>QKSSyNjXJ1McJK1 z9Ufqf>om4eg?EFYXi{JV|MiLdpz}ZXe%$cma^NBBj?hPYoH$)%ED?hO(bhuGNVJNl zwJJZ%(5kSff2>G@4HG-z+Nc!j#oEtne|&r%dJ6(gg&O+y(anoQxI!LLho6dWgomTL ziPl!PvH>hu#?r3DRpiWUDt!B{@VdfHMTLROkJ8PG0!bo#IXob{Gt3$hiOO!5O=f2u zTu(ZUnr%v6x%E+Zs_Dn){`r26iw!Xn z%k7cY9g_w8y3N9?2kYO!a!fJ^Bs+|+Z^uO+1vFFvwN9qR`6?pUkMe9E=nF6ov z-xrEHZ52Or>almu&fo_hTi}<+Nm(=P=b|>h6hFv*?6E7%$g;5YLHS+9Lu>CLbKBGx zh1b{KwT8`U(Hux0-@3=e zC;c^&`7a|CWxUOnwHRpsUqA@y{%xer$IMyj{JxO*FKR6NDt70Rt`CQD)_)8eY)&dI zS6O$oDmnPw1g1V}&mqi>+zq>(#C?xP+Vvejh_0=x2pffP?yo1eY^L(RmQm_zXFX&3Pgo9!0rHas+^!o}SFSlQ z1z9}zd|Z8-&YIwbtBp>(cM2Sj9s7`w_0UMP$()##s>x0q@!bFU!=u$RzkYo?Xw_wQ z@?+v%NJ_`J$qpmIt-t*Y`By&?GJbDnm^<|7zkn~-WcbRy{bd~!tFFf!9zdQb5wO9C zR}Ve0Mb(nMsxP0Cp5IW#@&t{eGjeD zU*y_-Bm7I$jpNoiS^n+nC+|A&JEn}@(sE{x6P|d-KGTSNpD_0np0(2=xUsp^a;)oR z92q_YXDo01e8{=w(Ot75gXkU8PxkEm2eCtdzn1^b0{9&&Go|Ij5dUD?r@x^s)X^K6 zgpSu)N3bA0wBjtkxN3Z+5%Zp*YI5Er=Vtct{i%~E1<(^ue-z;dX@ImrT`|87Sr=^L@_nM12cgz=E(7cNH7F-UQ?MQ=F=Gn zCXKoUl65-UxScZ9;|T!W1PmEXBqLE8NJMB+FG8=3^aSh-WTF(FR9^5@U22mK0GYwA zrNMxJmSF|G;>S16NdzD^XuO`vkBF{Sx8I_mlgNB{1~rXhlZON&DP0&5z!Qq8$-Hz7 z&nk^jhUAQ!XHp`HNozTa2NPQ-a5T>;ga*P6ixf%_IsrgtRO&mXhR5@Di`k@N1QY=;A~>kf zoC#)b#+KX^8mfL=u&ar2--#ML?s|=-L|2PCmih{4d{8 zq;&JCRJjZ_F47!E(`h%gl{zsMJd1z&*h2t1LPKb0BFD=j>ZWNLAu~=vuAbkOvTg?^ z8_>{E6;@N4hL?@o#GY9OfwCH)x&1GH4w2bn2{{UCuHwLXzM?R*h+iy7mMuJ|)A$fD zWd5c3-Knw?drv?N(p!4OczuZOrUE{!5y_xtno%^KFXu2#^UJx_J$vbF1_n{uv;<>R zP9*9JP!c*4%g2`?Z9qbC_$$6 zehO18mA1)ZSYeDaHo|^m3a=0Z;W4fRA{n3p8c0Cdp4nhD5TTUK(?rTkE2MJq5^=ng zC?GKaq6kAl@wmMNl^&c+hC{xc(Qpm>JV{JeYAR9F>2X04@NP^23B-tr$zphsN)fM( zSM!mvrMiSX~OQR8vNp(0$;K5u?Jh zp|n#aR9XL|Kr&&1t`@Q@c}-%4l9bd4YkUK1qUF);(IlxWhDIkMJOBYf8w2pk2;VeO zT4-}-2RcH{*cHi~AsdV6m|HwPhbNMQV3z>IWS+1mK~j(gN2 z1GXTgJyL2D3m!Kcr-oh|Pk~T{CnXn0AopP<9BK*ypX<4e z$G#Rr6`|eu6vXwalP4iZ)Z*>%AfprD7xI}*$Z}Vf4NS;30(ix8c$(E@#nZK;Pzbbu z?Jd98M9hXyB55uNPb5wdii{FrdXfPW4+#b;g+#x8C=9TwrM4AvVP%P+7M6>N5@2W| zD#jVfmE)=8Awt9&^!Vm7QXf#OB2qGQ_tF+3q90}-lH1mLamfu0Twp|dGPY(s>9%P2;* z(@hX`D*}%}S0XUQq!a}#K2p3JVMhy(a7rewTMC4PX^?x^x+tDMRUlh3AhVnRl(CQ& zC=8l=M4Y0t9=O^G34v&mb#M@c>g`4jJO{Z_7cRlJ?nS0-Hzknv?(nQFr{_EWMj0FY`52=w+U}0Z`JyXTjKxj{(owutmeI2Gz(4mFSw;qE%?JR(bUJ+ z4~<$%QL&l@oK$ZTe8)DqOotNgW8H^m-m8= zHf~n)6;EoP>RD@D(IcY8)s}_^E9^FI`BIUS{@RYo+#c*-``4J{e;K1B<1=4u zxeLwzFUCCIvSLke=#+yacHG4J^z66$`#!CG{<=}^e%`5qxsFzqS}vI+G{AY}1nf4u zuxYa(&Sz&G7pBIFxVE__G}*v0p~J^#$%Ci2s_>KZa`&@QNCT_4EdL0i%cjD9@>=wR z0|6616B(B_B+MLh^Ib0;PSCDAW-QT5=>KGTVJz37sXo;e*egE(`D?K7Uk3k9oUGS4 z#rv-YFS$|G(fqMwvCGRH#;>f4veTjX3+p&XK8!^6c?F({IM#l0vlg$vj5l)1{IIS? z*2Onv8nc(qTz7f@urg#`)k?$-1W4t89@1#cka%C62(1X(T^?}eb^WHWScG( zd&Ymj>GcZT>S5`_O-k5p@{pTRbq5aG|LR&^#2Kr$9@}*(S28hky7aHXkY#@@{*Cs2 z$BXehc9Z`zk_rC}_h2^OAKQO+$@i-cXtpjoYP8P$<4gnOZKC$$Hj|P=ksF>;vxV?w z;I`tErwSF5UrxWNAA^|eF;2I^9Ci?v9P?*d(U#bE=p0;BD2qPG6RdcXFtN~%=ZxQ- zCWT~`0WAnYE&sgyz@$!xeO7-@O@DTsT=HTTsS^cui;QD!Al?d|eRP#AJy$1i5N*8> zZcyNQ8&!nxgD`^&Sn>L~2nLlw6Jhwie69&Foi0UGiB-JGYCm-$++QDCrKF_Kr?8!eGc$3K#&cxw+zE z4K$=wXVvZ{F16;SS|{k`l;OZ$8|+a8UftKK5==Z()qtSw(m>`RT*P6Fq+~pVQd)~4 zYL}zTQ^2w>o*dy7BpTI=l6mV*D2YhcEL#KSOc>m~&Cdr9KwQRJPW1`hEdpp{s+{Zv zFbraR(+D9*h|b|&RCLBuQ$`A3q+Cv9iy)kan#tVK%bH52ANy{T(9Zg3XB8`XqX^F< z(i$=YBPB$!lNFPfK?8J>af-0?tYWGgXT~2YHmI_*^5Ctnm(Ue2U)wA@09oRHLZezY z8i^4n3Z<1wV1Ybje{Ms$zArTkY!HB%d>D=@98uW=GP64-oK9=FpJgXVTy)l%OWKOQ z#2e+6%0tu%mDrh8W~Q}PatQr&(31=lL|2Nvq97y)6h*_~tXi{PlArU4yz}kBlI%(n zn=K7NBGR9id!ukqJEQX=hcrTv7?jk`#Dc6d%;Y1AAr`wbkD5IhlG93K-jVG=;$AVO z4&CF_tdb<;mJ3D)02`_)8Y9+93o}1y{EfN>bBY3yt0;xS-x7mMvKdyqGE%>WMzfvM zQE#f7tD7HFE*Uywp3GH2?JbhV>KVg&Xg&x{9{<#7ZEmvmfmRDsYT_A1Ad{vKD!9?a zfoKeotU{zoq*MV1N}ihcp^R{R3bWNJkbVTu%3kB%<`F9eh%t)NjP@QiB`e^Y$4Wsg+-cmnJ&jCav+^M!J}KuqwzdoqAt6w@XDFP)Axc;%DI10Xd(+$5 zaDJY3!85-LakLIzK0(C<2*qYng_%?;4#dPGI1K=BrX*jAM2kD`qEbP3vR#@Bg5knz z$8vRud|#Gq6NcLXHauxBgrSs{*csvKXtKGcO2v&23=BA@5l0g!<3IL+&1Voa+0*%5F zgn5#5>JuUnZwEJ`Z+L0!Qdb@rkwWv$vq^-{*sDMSgSs7*`+NIg8UzqrgTR>s zr$B*hBCHvGyz$&c`6etl&$eM(AUTv&BmpFrlX#*iz&vH13bNJ zAv}XZ61rUx-e>|D#Y9bLAPF|83E5jWTarFONQGIZ6Il>aS<-Vum`<+=(g7i?1%Yxj z3y)94;CztcrDnm^auE4}WFjP-o~5E8b2E2Q#fj@;Z7HpQc2!|TBV`grD)iap4N3$*xjEMH8e2z%mh_0g4hFQWBatdfq< zOPe&U-j-RrN@>kRQ!3tul7{4oX-o$PJE;*IRp2iz7pn`%+a@^bJVK8Sq9+r`M3a$t zWf&bG+i+}V96Xq25lLpD9X{!kKq54pQB}NFLJbkDnj)s#Sm9-h9&Xgc>9j#f31osX26TlNW)c~+ zOnf_5A+{_v-0SL2D0=3gn)xLyyiBTCDZ=wXASendo+7Mo* z1myChRTxiW9RyneEpt-9VIr030`O)MY33^*OSQy~pFnX0p`T|hrU|HJN<<0>w(?|- zWNTWUN+W@RWLNI7Dz&+|J0C$n&ck`elyWK^B`qhIqVj$I;)Bw^@Ze9aBCB_J{Q3sn z{%-_kEO@M@ar4ZHo`m5$(WlmEBrNKLv#6_PL#>1x`hOavowmGfrDp3G@=U?@PSPUC zxjo7yN2q2*y$f>;q`U`z+cTFS&f44VDW79vK6b46LOzk}unf*>X}KB{lx{%VUfjpJ zND9&%4^94SP{Y3rQkC(mDpc^$Qkg+(ecr+g_PuFUOZ(|yN{ zm$trNXzkf=zv_yLeHOAdQ%Hg-&#xpYUfKA#W6MH&^h3K`qoQ!pxy|Wc{ax*NE}N%c zy_x?qa%gtA^}V`NhiSLJ24+02BX6So6@WSBvE?PP+Dm?R#kJ`@@&C=bi|} zlaroCkHQa}xo@CgjoTLkbGaGqMS8sK8!P4?u+{o6+tp+|J|^M7ZWvzuy|N_Fn8vS9I||C>28cYshcV_xn)( zuCdDae}wo>|HgcBOTC|LdEqi|`^_3=r?eDfu8W>&3VGqHd+?lrRDJ(~I*)?Fa5{NP zHoBw)e?>h{I=jP~Kur$?a#=)EfX^Uec~Vd)4FVLc(%)A*qcSznBedi~D@(GRPI7#a zI1~|u#`uB&-&YeQAU-kD=nUe5z9T{Uc(OF8K%g?k#y%$?^nvu>&Y;YjhroQQKDj0c5jhYJ5Dye$!OW>K0x5v$aB?s2wy|FwKx}ne_MJREY^js zZzGQnpaVjlm_#itCu0}@oZv!06{)l#9jLr@*@!}cSyno1w>TQcRN;Vu7`zx@A<=+@ z5RGYq#biA1gS2OD2Q$-_@METOB?6AC2%vG$0F?m(EGSUoAT{FUvdghVb4&B=noJ6j zk%sUU(_H!fMg$}=fFFioVO#}hW0cBx22+$a#g{(ov7>u>k*%veB(e~NEH470v>crs zagP|ra5fi!#8Cl9jFFn)`b3f&M6RJWxJDtG`z=x*l!?tsV43hML$UVrUGS0tD@S3Y*}z zHJXj8Ol}8VxF$T6x_g|K*A}qkr7%be5nBG`?XPHhp$j0x*Fql9g(k9zvTcHb(dkv| zEi->=najP7|CEp4N9YZq^kQjZD$fW+Q)K5}0HK6{-(4)Mu+s@oDlgGSNC9ex5}8J8 zq7$NcHbj~Tpk@&vJq#SDw@^1@^NzMG>vgK3obz;2q$`HXm+kUId=H4?^?34xLWxui zjAm4G%fGAm)D`sG8qkP&s$IuykB&Lmr%93*6$`U;rtVjmGeK$*p3Lfoj*pYdGa@ zVOn=eW0OcF*+q2mNNl-rD+=;t8>&5+CdL?HuoTa{e)I8BY9~!T8I}{eWn;b;GC0Dk z4>L^j#?Ye40m_g^a~;{{$D%ibAm3Gqt_%dZTL&ISCyZv@o2t;e_ zNVXjYzRkg^9dpF3m7+h&<5craUD3!Wfi?;gIW^*`5tEV@%}0WKe;=*_F))z8qInRg z7P6strctp3Ib=vEIfRq_DV#(Mz{2TwT}-_Lkv(Is1iW*a5Tv=3k%(zj2GN;-;@y;R zDey)JqV4pAFrsH#jHegL!fHLZazFsG5WxURAQYPd<%LjHvR!UrdOoQ@Gh2-x8Jxz| z6oG7_Y;~ZKY=S1L+yg=o#~(#v@$Ncfm2^k~5Y$fxBN?C&U?R|^)pU5#??{e--%Kko zRa!)6*cqRoK!tMqUQX5Q-FO*k8qJSJGGcA0_ajy0m*aV8U5tQ;O%uvfxMZu|CIpSI zCm`m_X|YsvES;M)+M_V2Y%u{SS1LoQP{l;52eDTU;l-!5mx2CeLrX(wqe^{=vPccm z7;cPq)?%QEBoPfFW}ws}xF`Iqm#!_EX@;t|`{T#tzg_dJ;KAuAG0{sH`c;ZhDITsD~A1<&T!;T>*w^7!FLICyQ|bx=p+KT(SU?BS zMw=<(>ejiMx}snZ8!Q?TOh$O1v2bo4fo#K82v>=RL)wYyj$Z5nMX*FzMWa^pEBRs! z5#)-f_OwJnnr0A zyI2j<9g2%j3IQd=HH)iKSesEd?Ss0qF}rJ$%a4REq+hn`ES;nB>zDsrY^L3MhwnF5ySHqPY4^^M49n8#yh-ow*tB(W( z10#8gNWo*b8y{>`fSAsm(3*yM(l+HGOE~WSa>;gw$PelTvUdjo?$9 zd`o}SVlPRM)eH5C_%YpbB>G4awMXW0%k|UyT{O_+>valG*#3n>xBl+re`KFUCIbVfQ`-HKw?ouOZ{L^MfZME+1`^|1`_cmqc4w^zd9_C(tfIV8iSKWW- z*wKo(&X2PUF7P#@Nm`?L=1n-yyJ4|zYpufxei0%!AZQBmb-G53bLz3$EnZ~P_TI`X zWTkr!c)Ra|`rBH~W2dw%ZbdC!kpDmPM`&M4<2#F4U>^#U*C^ z^43YSrN#0I5w|MV)u3M}U0=;uZwD{hqdx08`g*CdHaTPJNTpi;Gha)+ub+Euj{Bdf zy97%LGE4k+_qOTMzC(xC#~y5arn+D2#?=2Km%XkCM-zQJb21(1zx=?8a_P`=6|`N) z+Y)}2>#owZ|6AGYM5+J-_~|pn2Qe&H&C*-u-w$G>mp>mMFiH+?_h`=!A6i+WacIgl zh@f%kgG(#FUHnsfQrpreYo!)zhi1st#Y6_$%r!Kn(=Y`YVr_f^`#vC~`F#N9^X=-p z7KiU(1oe&b;l|q5YBQ-QI~dqad;iPS_UhRZ>w3kh0~$N9HxuEi^SX-n9^ARMF#0mK zpxn!>&=7@lW691Naeu-8I3qud?Z>nm^ zFLsa5D8BKi4`V^-WfPGCvyhPfArOm_FOu;C`3`?{ZRI(*I{ij9ah>jVwf zttS`7m7{ohq|6k};ybslM<85qXA_mq4sOD-H~vfJI#hR|6EL!wx)W721TsH-Wz+FxT&6Ay!W`J5uw5Q zVO(@ePq+1*Mxz2X8xPHa{$kKqIv<(YpLx;5-A&26DBQQ?WN>q=e7oit)NtRdiuKY< z9*1|XyK092aL*p4y?H<*<@p)!ap``)@g_lswn3K7s#U92-|MQ&YR_amxdETuVQXvH zxEPL^-hXUQ#bNyvKyjZ+1jaZ;lknOC~o0mxT4C-)wgr>JzX z?aY`(Lp>Xw|ERuH3|4M#s>BIYI$5qmu7{Ot9M^pqmQ=(n>sXxknj}7jJ4-w3VVPc4 z+#?BN?Tj7ZzFzi8f9H1Vw9Ip7tyE9m2d8syKk7{RRDaTTS-I6#RE%mw>!Qq=*mcaz z%MGeooAoXaoHX;L#aGv-y(U%Hp9r(~v?wEVY>)Tw?#5W-7M+H|0}of+ICu7B?wgRk zev4MWmgS_E^p7Pzt)!Fmhf_x{Es9he=toD=a{5Ey&BQ|$G+WXJizs_h!KH8u6LDe>pnM>BS!Uc(DP za7-A9$z6M8;U7}%^>+K0#Fc$f zU+QA|cN=~CFFVmP{u*K>^1o&4+(F-@&!pen7+Xtv#WAA|yl8Je>2A<4lM@toDfDJR zs!D2?vWxqrE8jXQS3%w(&AzYH-M3m zkG#Kpk+U-3&QhGFjh4LIYIyi(--?--C3VM+ox5~#s8gg;T_Ua?rtd#@^Xi3f47#LX zIc|7ki2L=U;KBKS!Xc2Lzn1@Y<$vdVZS$8a{?GFpx8J@z9PfS8_s+dXf|L=%4 zGzI3&nYy_<&@-3U9=mzPU;y@BXfGUM)9G&Pd<#>CmLes-`7VBYsY^`%2)f)D0}ymE zmcbhec$3I(G^9Z(d3_4#9Vmh3GD0%G`kP6hxcWvixCRNezM_%(tMilaE25F3NVgOL}yA z`rLfk=VdvkJfv70@#*5~`x7b<*g0DTNVXfk$|8-%B8UM*s3sbN7a$2JKFH@$g*-Bb zNTGB0uvsLvawGW^zC@lya)uwFl!`rzV4x6zD}z(1MkoT6i04`nxHh<5Ty>4h3>A_@ z(|3DFeSU@-mW@V7r2u?Bk3@&_hNBwir^dtJIfj1v-Zz`9q5!JA*(cf)0&e08*(mo~%89N~B_iP&uIy z;52Nu@B{@Jt$WKK(sm7{DwUW66acWTn-ey`<6=4;$@(NRtdx#sAww{9Hd><-epCU% zus{er{U5~itkc2%Vi1Fm7zT89DL9i{QL@tyfIvjk0s(R`rZ9ro3BYOg$z7Sb%A~gQ zZjm$wJPeJYfkY~ephN{x#ZMAKOw)>W+`}}U7|6S+9 zUF+UrvBHGtZmv&7pUGie^9nG6Xpd)cgZx*Id(T|DdYIcX4bME}-{ zF-%wdkg3yjwi}Hk1B5LxWtq`w$k1oNDB1?WqpCfQMT&{3vbgE_kwEIG!gC(ns zm_uLtjL=HS)&43yS>RRJhv-D9+D=7akde9-5Cle0jI1P1hw~DoBV{0MAXId`LqDQA zBbG3E?R4izaHF2)ltKaAt3Cra#}^QQjp86R?=JS=(t&P{!4> zbdaO~MR6QM5Q^B160v{+7m)Sk3DZ)p;~DE6B0E68D zGYhw%_2n9ghMf+2aY(A1uc31T;@}2C^4MsCIb6UOP&081MOC;l7XnMX*+i~vKO|SJ zP8OV+u{RJT9f7G3*~+x%aX_3R2IoRtilC}yK{h?%lQ^4W1{~&28op~l<$}!1NkWJ$ zgHZy3sg@aF;s9|RkVz0_^sT7>wgnt7xl)L$aXu4llSZqkK7KCsyu@JbEH(#*II&90r zBf5*V9pm!_W&}sS&l3kU0db%S5)eN9Txgw#!-hXe0#a-m9fe!fiy4E`kK3`kP8xVvDkq|XVBi5iD zjnTTav~a3+BLUqJckqEA! zLPc-{#;NSZp$0&_sJT2MfB{xmUOkG8BXVE8XKR=KAtH6rS?*MwK2Ml=^CQ0f-NF-#RlD_5ym zZV*R)CSKmAkWJX;HCxtebs!1BG-~}~dMc_Kz&MyiiLl}&aLg60rXrOtq${D!_-shPCK!1>Mrx{0!Fjbwo&|gRxR!H!#69(y<72_=!_#izipfDVrn| zhhvE9YHUf4o?B7-V7t+UTL<(yNHh=}P~azoF58iQN7M8MN=RxBiXcX8J7(!u^L;uKuvkJ8u2H;X7KEM?DGoGHjG~ zr!}eIWowhnuFyvR^J6`ddRkeR%(q~cA>Xz}QQ$t!b@Y<}};_PX}cvG1RV z`_H)a;ot~j@X^(VF}2Mbp58AQ61ifZZYza(KabgRQCVCPg2^y4!Tfv8$SE7;OfRnDP4pL6=l zVFkJ3?zf{;VO>D{BF75m_={P|H|u=FbphiXzfKDY;0xwFo&67R1-}ETe=axbbEL(8 z8hz!zfUCh<#qc`3eMe33hk+|CiV5RFR;9e#)e+XFp05`nbu-($8>0$7S9l~pnU&Vj zhq+;ZcS-(&8|9A|m9C9^aeR*^qHo>TAyI4H!P(UX3s$%eVwH}(;o(z0=e}#$^aIj`A&C9Lh(|4(Fse?`WnA}cOC^y$A?{{Oea^ZD$i_s6177k_?T za^i??_n!7cPghRNSUFudwz%L^@#+owAz#i$7(-L`ot!dwT}Z-)^yw2+NnMY7E|%5w zjrZFWnLjw$cR;|btnQDwt-@b6Zp_-3@uumo_IKkBUM=ey`1=nbDU%zt32SMrkhg!v zC&gv_axj15!PPDHv9~@{{&Ul0{XKWzWBtzZpTxiO!+&7u>F==A(L4HtQyZf*?27I7 zs7Ie)Tzz-Cch?8U&0qc6`;l)(oScnMBhd(0RT+h9eBu+?xdqioh+hA5ppNK5no!&f zn1Ea$jdGAf09*|i87iT}pcE|ADtoMaj@injfD#u& zv7s{Xq9yl5Whm0dnczsc$9g@lRA^w83BajTtJP(l!F7c_OgtXxny?s8`LVi^5a?X@Tftc+*76Gl=$}ZyvXm zcR4*ItJB&jrp~p=Vq$k{Ts`rKAz-IvA3QmgcnaeExiiGl5qOgZQ>jckS4Oe5Bp~VO zfe!Slnwv|&kjjVSix=mKSXe#rGk`NHGx~b1cgj6eAni?Gm^Mo;({jGgjf%w0}>=KYOp{v$A{@pUnTJ%DdA{a%$ zi<}A9RD6AT>bhweBu1fkGeBBEE{g~$FWr*eEo||#MBI=xZ)E05Y;ra$)nrk`!VoUO zTLN5-spS@MBSK-JI0%hnXEI$T^wsb0!Z{BRg+fAPSpFyxDD$XN0H#0<5~4(eQaA+F z(_LV~*X@X#qh939jLD!CCJGLkd^v)2ltmJ(VzEO%-p$-dtvwY*1!jmMpDLtNj#MX} zm_c(WX99=H#vMspnKtW{V@q5U7U9jYS|=oVRXRH9`>)}p#Hv5FAtQxH8}tOsNDS}G z4l3AlYY%yw*14qSpq}Q!$sMLBlAZC0d`N3&F6JNz0-6~pg*spgAt6K}6Pd?P7BYN8 zks>^UwqG-%f+!e*64eL>nT#FgMvhd91YmRr7QuS#3)Rezjg-kUDut^#X%f$BHf zf|W3bT3B}mB%mPTcMM=eTOJ?k26AK5a{E*om4))ns`h8QOH@^oT*ThlK^7VbBmq>( za}c~4o6I2CO^H`=dK!{j&8{`7fDJ-MmH|?OYbo6zRbMehOb$#PzK8@2XJ9!ziFKiK zaz?PjPIS-cy0^?zT-+Y7Qn7PH0(C31?+_iMW#nj3aG2-SjKcp+4SjJxQfWr7c`q&m#Z~WM42_2MB}Z2qHq&vEsJq zyY((~-OVL5VQxU2ewWeqJo7#J*g;ant1xd2 zQ3_cIRnW$QCY8Q}WeRdc<(_?V*@8!G1}Yy6#?9~&gA_hV{L>hVEDi~{kY zn9Q?+Av`V5gCSyO2rIWqh{ATQ5aZ@bFdP!d*|-ViyVT{IHx@L2;1N>Y%=YFfqcMfRf*>jb zB*3^-dgCx8MwOA};0^C175E%3$IO&Zc8m>BY7-v$u-j469F&!h>y%opkc}f81Tt#K zn7p{F?s`4Ql*Oj`N^;wAk2@n_)>Y-SMcSuy+T?ivY<$&eG`6>IOc7ve*n5ZD&W%SZ zob??C_KYZ^`vP^l7PI_Vj|r6c>PM^GpYzZ;=r!&gQ{9-BgEUS* z&zVccFX9YOslB$1T`}j%l+qtdjV+-qwEnuBoYd>%;zl$a<|oo*)Uki-k}8!_TA0pj z!6(zo+{op1S zgWs^^j8!eq66VhNnzSTogXCoD1>a=G?`aTwepBQ9>Y`|A!XFFf-U;|4=MT443gLnH z>mi350)qQ-9!`4k;cM{dn%86Qx38Sz z#W?c9A9T^yk#8e=p1qoQY{hP(U)3Y;%YTd+g2sKDb1q}d8uH_8pXO2PcF0N&Z&ROD zF8g8foGr}@7e4&eYx0<-%e9P?Z_b`^Ve|sOohzmrTEK~SIvSGJ7$$z`y?Z5cx)vjz0!NNf7{_J@l}7En75?-;WgLZ#lIe1 zO;_K%c*r{Ga8IzUrF@x7U&Yk4sb^jkNiK}~2aB(yW{A4J&)Ih+>cw|&&ix0A_xX;+ zorXs5Wi-Vdd41|)`U-PsHKYFEBoeF%hV%rNnSN&3>IOfsMo z@DlTw0eO@Ha1FO+YQs1>%>`^>;CJJ=kzl{)D$hhWV|!iUI!FDY#w#8O!$J`(d_^>f z42|XkmC?Rpl;`PD>sPw7!jYUdGzrWTSuK$=O9!TBFs#$gsFMU$l-|meC?Yxe6p1J> z;1IHbYiIb^7nTsI+-5~}8fwmnu~lL5{-yv17FnMMN3(+B;!oqQ=1@xz#(;Ks=T8&$ z(Fn@v@Vh?u4$nv^p^2R^ifWOs*XCI)=Sm-pcDTHvX|bXIw^J^G;Y>lrY)#&)We0!BcmtNs+@BqL4K;iBO+iCD?8w0xP(GKkMU%LV03Q0}VFa(pl~7BHmJTqvS? z@0X_nKT2@A1uXX>0@`_+X+RQ!&71E;xqW+?3$*>I zhR>{4X)ghB^4+YAxRiQ_Ts-8XYIk8>U8NW-kIW~|X*jlNtaDIdr5Eklm1-Ij$fbQb z@-lHg7G6rb*dRya!kJydKzeFH-r=8#Ld%jf7wQAZ<-x*e$5UTcRn}htgAM zQ)F||+N{sgkYB@HI33J67^@-RydH_l)7q?2aXHNCVVDjq?Gd-p*|T
%#>aupP@Gy~&H%sFEhoA%A|<`6{w?gu<-m(F%W%;0nmh86ua^Y-=lf?q& zgh?Y|X&^F$<<3DP0k=MIFJa_QW4ZUSvBloYO{+!kQl|BVGi722X8gCOB|H0QWT`+- zFc8xLQ~bqL$g`8Crxj=Bg1vi@ZE3qlov@6L$lQKAs>zX_zs=KbjF86^=;4-pZM#P0 zGP*+^+_EsP<|^VOhv#xgZ*Fy*%GiJzS!e)haRanK;^MjZD8_Z|3Z6V`=j8~2!9SqW z!Zk3wP{NYR0%W8W#s;MthH9psgM+5RNW}mQF9~So+{7PA%rQxTi?EvGwZRoM1Q3P*iqidKzoh_4vGs z4MWwgiRwOZrK%loucoBwh!ZH~i=ACV%gfs42rZ||D_*zeK7SrA#rT93rZkSlh)RPR zZ80DlW)W4*c)#c?(8HKVa8q!VD1>QiS96naf}&vJAlcHchNVt`Y-b-9Q%vZaeq~=s z$PDTq$4Ft@&~!=AH8es)32x(wXb-f(4LAFnxgrR$xIy-zG#?e!3~UcsZ7P7*lpJaIS3AvkoXa$e!2*hGzqlYkj{S<}87;=OsuH}(k=4)j zvi6mZma)&TG;`OwW->~3PBY#+s5I6+*1bpCm&O*jGW)zFGCJPty5?ZX?&ge!aG%x# z&zLlxIEMFy<-b$jM;ud&k&;Kd{|c66k-K6BF^@`h7_(g!NrZx}kwF*T zS;6M~n)W`Cf5U{7e_<*f1rrwd&pAua`v>-kzZ-L?dv3UTXMPxQce(Sh`Bir|(bVrZ zE8eeFe30oTB~a>R_LC7;S6o}4^L6w3Uw`(h`+6bxPVmJABjz~yv^${re=e`zeQ?av z;5CgAn!Fp2*T;`ei~r4`M>^-PL7yhX@ot373|zEoU*VrK?j1^AOSSKvoS3q2u&ELK zZ!5W+e+9^=0u*i@ZKn(Vg-iAG9^PL4^6ta8o^^Gb-`ItR=XBQp(=^rp>${%RcS}}p zJNRF!0X2mee@cHFlgj8$(MQl{HT>D2tp%o3t6tz9y=ViM%lS#ZMybH zcv*^nTa>m(!x~KdUrsnHyL&P!TYLZA-#+4 zoGbrmYP)I64%Pp?Fyy~tA3(+4GGSckKNP>0|36XumX|@EC(k51G&??BC6Zhr%&(qm zaQx?$658CrJ!i`*_7~h}`2UpWw`xTobYc8DhIW5j@SN5&H`ZBnp}yI7dU89j?d5RV znuh|@rj$uX6s5$Db{%lElzza~YoTOLEi2P2^tEf->HYiNPX2i5)tmUDkBhx${BKl0 z)2mH)Zo7>fJK^oq+W*1!TOhSB9}-EP>XonVB%hb1NG}c3|2Ak?pNW25V_ItymNq9I zl2=}R;m_GKa%o=bx9G}^12ZJkd>eM~Mn9GK2Frshq-LIw_Wd_`UD9O7wi!su4qXWY#7gjvRyDl`Nc&Rps2jbV6P7<-q1?H{Euujot8q z{&0H5wQ+R~&r9+I|2BWKBnc{9vO&f z{g)y+2WIP5Ktms^PWm-W;r{riV$(XCuVi^aAKoirH}YYM!;*>9O-&n9*uwuX|E>d9UL>HNt+B%n z3U+H-d*M4>X-;}oV#lCWZOTR08}o-%{;|GwR;%?Fnc+mZ>}&GKm>-`W|KNV`#uMkN zXJ1#H9n^m!8MNrSzwAtvLnD1w8kb=>KPgn!XOd~pD!oIC`DyKGXE%Jnx}^3L|u^Wsmex)~!_2_OGP+ihO^ zvGiCY0~6Z#VS_nKensTR+TWEYSQFYp4vx5EeHo|Q^7On(mF2R4FS#%*`}dbCjo}9) z{QqTR+j&u&efh-L0(hvs;XRIak6k0p1+2v;_Mc%r1_bG#p zvD(>hacOrp)8g(op;Tg5>5zh<1^&%b)VJ<$U$f`jt)`K0#x+%In$xZ!*ZhxoR%D`$ zdn#ux`rjFZUHS6V&SUcWVH;=*{j3MFG2eGBAwz0s)g~;tw=7SQ^+TpYkv)<#yZ%VF zL3gET{%h}z9+zjPa-3gZ*?n|ba&u$9b6ND?({I&2zpI{qbxHP=9lxC3Fwj>m>n)v9 zmcVTao}VrL$JRFdK=u@%t6Gf)I$hm2?B$Kf*{i)j+{`Y&m!1DH%Dg&CJUu=19xwX6 zFlqj@Z3j#<)d!9Qop2aOaB|GvmV0>_g}}_3$&xjzlWhpzl=miq?PH7$mj# zQP=m~@VhZZi`?n;e~Z@EeeYs8`$TU2qh{_d<^G&Qy(esK+k2ZoKVNl!z}cS;JwOby zH8iOzS{q!JGy!HNmKV;~`ECq;++M>ikfZUiI|pcR1FPBX4h(Ho%T-Z?|F)sE=<HNZtT(*kJB!qEJMZ3~qN3ls^;3Govn<6|>tHO$m~piwjudZ=V#ly>XmT~MFo zso2=+c!IPU)l{E`M=-fOH2^+zf%~w;;kwo?EpFx~oSN1RX|^~^6G1ENLZGA}03}ER zjjBol9-fZM_xJ4wKKt1vd1eFp*d~=$BSSz+zeDwAsLrMhiREfgYw$*O+_>#!FJGk4 zVrVqkjuB@VKFHyuoSWkqDiVl%@8?J3(HII|EN4Md(v+mwQupo&fn%=?Pa4me;1)P+ zD!q~x(0JjG_{s6FV!n;4YqD9Y%{WzB&}ah6FbtH*Ff}GI!M>2F4cVopRjP(60#T=PA>~6}8P@LOPFb9)w>Jstsc$LNOPI1do zW1QTbq@#TWT2wVR;FRcvG$t0aNAJum4Ab^S5sR5(wsiv*#2+(rjWu)_s25Q>>mZL2 z5A?;H_RqF1s|5=ufOf)%jZtY1F)CW|-Imm_VOmK*1EMNW)$oDG(yLUhUdI;roytatvperlW)NH<6FMSzJ{W&diZZ zjMdyYNS?$ccTe5v9rtnXcPb~{A+RE9$bAeTpB}(ns>L zCaEN`E?iL}@9kcd#m&G~Ra3{_pbgPe20>&8E5@+BhAnlCN#_7^CK)$zXvrF)8zBoQ zN>eg~A`*(Ar$CvfVReYwDcu*>*|7<~hLUDX$3=}`sLC#(4jl^QsuFN2?2guw+E_t; zMqo04Zep;c0tR)bq_W7+#dEW2YWKBJ)5CPpy%}Gz>Cn*Rl7i~&?b$&%5+iT~3!`j! zd$FLvuF%FuJlcNX#T?2?ff1;0*uxe}c&%0HzG$d|i&sH38QtIr4&~q)$Ok+|Buo(P z(jGpSdS4JLVp`z zW2IU!E#?cODx=M)p}J$int1)y4}_DW635ns$T#^|B$M{!eCgm)s789&mHUBHb3)nO0kWHAlI5o~q4Cr461=21*ZB#xtI z#iDtPa6xR)a|xxRk53mewV9H<%N(Lakl|!7NJtaLQV|*nQILlhRBH|SwzV$fJzsAF zc4Fj}U=e|y22`?k6~?S&GPJM*!D>}$^T(erp;#=_yyCVq=I?1WVk2xe{2|-jOz{e+ zXRAcO3eZPGYKdl{>ZRlq#GTP9A$C%)K$I1qHupz}5_v54UG=IWk`**{3CkAf(j>{l zB_E6ql17jCE8Z0U+X5O}MJ$+$`v4kD-AC@@pG(I)Jkxi&CwF%T#B{9d=-fm(96(?I zfl?XKO`wrcdOeuY5%5F+Nw=?$y^iw%BrEWSy{1}?C}0Uj@y8HZ&*i^&>dJo=tX|Yk zo!O#2Ous`F>fae-f$KMa@`hIUKX}&t+v86y4___uS-Ej5_s4G&I6*;vKb$!6$C%%K zEq%7s`j=Ym<5(j){Dw6$baToj*`vgupJ@KwX@`$dvXURf#(q@>?8L7#_M zw9HZc@Hpwis%rS{@rt^=MDL*Hf%W?fXh#~vGN)l_Ek*ro2GOR_whKW_r+GiFRl46w z-hbuZ6hr3~?-M(J%xTLE@A~*-UIn-QAIs1Be~o(}HSUz+0rTlg|J9)Q&KYx`9DU_V z_p=e-&_mr0o%1HdzH|yip3^j$N8PPQSwq~9j{2n}BjTn_P!al6C>Z^z?#7?^(sjD+ z6^*aYLvEuW&zwNcsjp?uue$Npep^7N)zQ@_O}+`6$9Qu+I}=Z!7nYmagh^x6-i}3o zuSUziqWb=Z(x*3m9!;a{+;kRd@`mtv(7zT_uN2$c3_pNaqqa`#y_9$k2QY`y9SlipTw@f~&PZXSa$Ub0B zVNSQuf9i@ib1vQ8e*u0k8aS&bVNl9H*sq!joVvab<+~4f=%keH zQ?656z%Wa6c%A?>Q5NO2Cn~ytIM13!tPbT}bT8*kC&F5pa=~1!G_sXm3k?t({9S@K zQKuFzoiXB8OL(~V8Ws-(+NY5$$`eWz0Vv&ENFTH~MV%TR6rL&}C8<1jAf?y=G;E0^ zjWQFhzBrWG$l!D*xtp|w1+32%>XVvHSUk+~V|ZeTG6_U>6{B5nO96O= ztmTKO^O!6Z+pADSAY#zL30e+h3bdWx2q@A=wr9HL5Fu_3PW(>LUi@r{tiyja)>74h zekT>{xocQbN`^5pvpPGA&`W3+kxPN7>CuyVw#7OcySNSU^{uA0IDP;xGH66EmV`37{k;D#T>X(m&1^@YU?8NN8yvqZn0eD1Vc8DAlM=ibO|%`5G(=nCl#bJ_0vEasAay zgJzJ2GnG{l(0@Bq^4&tF4zSj8)r0zks5ZQSbF;Qia0W?<>-sl@;>rk-MhV+Njwm|M> zv$!&V?!hS8%=&;zDId?dpubHNI#;E}xYp1dgT%{fQInAmH&tlpm#2xWmAtg_8ER5s zW0Mt5sFYq6#>la>GE3GNIv>(X14=OMCjMyZrD8y8YM){lhl$oWN>-l15i2wbaueDD zlRHtD7_vndohgnKpPMJOa7UpP(I7=a5o<-To=1ccR)2$d({rkv(F}GiSNWqB8yuj* zAS+?(A_PkqN~SFgmZrw>ZIW;`&oi@?!&g?c!{fK;2IAvFcF1)o#J3g%a6 zFdG@9;*ruch;E5z;AAIBd0l;WAtYGK7v>%GJPV2`1?JkSf`bU}axBq}l!VKO)Rqzw zbFOmLco{(JWjt8Kd};&>=E}u7&0H@Q+G&B>F_%6L`Km-!cyWiwLSBprnFbo3P=D1+ z<2;F=P>37G0x4rppw-O>mW&Urb&$DyNnVs>9u~(GSn^Wcs!G%gZ@s4YJ~Y}HnnU}Q z!nC4wrWc(zX+Eq}sh4GQ0*+76MpJZjrf{ubq6CSsoSz$&+wmcw+AZwd&hzV>Y0af0 zkDM}e!mWglK*zMZSfvr$yVy=ZLEUzBEj&`1I$6Io{Nbl@%oNs(K;zchiuOvWn(by~ zc?TxNx^qf745gl7talyRq8`>ECRw!_O+E~4R!%!#Z zpID}d8TtW@`>JJ2cChm^R8gl(P7~JF7_$^&Rk>_eMF^<%yC9(iX{vYX?HFox+n&j= zSmH-8k#H8KJx#Pj>W=ccJU&ZphhH%)X$Jp>6l*@wp?2YUOatc0$nv?6tdM-b&l8OA zL}iBQYzr6T@_ol$_HMO~ln!XuCy|s2XRGL_;|&mP#F}MP$M7CQFQ2 zM!Qnx(LVr-&kuNF@s#aEOJVY-U4QS*UH`%tgQ>ka{rITK^jH4_UtHS$bzIV^H=Ck= z*@DFmTz>c4&5K9&d<^x-B+q_1_x2GVXr7oz-jYIprMax3-r$Hxh;;#_D)KOg2V z%uY{#l|22lahjdiQsAG5>r65emQHDJeL#_yBPMh3>!HToV4 z8*P?P&02iy{Pbm0AJs?yJr585H4g%69)5k(BmJM&pZTUs412rfuhD-A{pTsy zNBDMEY`?i#t(bVCXo7Cru%rUy1T;$!^lHe*%n08BLwuU7rtDUB1%VHrI=r9H@Q(@$ zdv@5o>o@0-SBNR`b1F!fcM^_T|7Oi3?bMZsU ze%RYD-)8z6r`$bJSHADvor!-3{Pte~!&JcZt;z((Uy0Ppp>wwC2NpkDNgMu?{!8?= zDQ*{u-~C2!sM?Hw8JF5IWs7F=hr*~qH4U$%$te5potAp>{ui-_^o7Akmx3NA#vLr5fYW9Q^oyX=M z${Yfm!;`En9$#k+x#jHFwzv3RyMWr>iQ&;;_g0<)`ycQvP~+C)J=n`D*bhmnUNJ!>8ht zopU=jFK&GLxc>3UPd!5rWzWDF1A7tHo#m`6PsX0?Dz?)q3;bi(?U}(e7m=c{IXVdPsX*mSx zk56EQiOEgyl3R_NW)G(v+v~Z{X)?Xn$!n8l-2c(35iEfNP`}_}1%5e7wu9#CWjTPI zq6Z0sg0yh3&c-0BZ2E(UA9LPWFl&=T!`mF$=ZK^SWjT}&`Bh$L>Hstzg;0ti-srTD zhA>%#oeZdBg8e2(f=($D;cwHCNhf=SskT(M!17T5qVrp3;yoHy_ z%@)RKZ3L2Nv#4zqnKnyh7){oPzCzcV!(1plx^rO96r&y{MPLhx$Pmq4)!$nOI`eo=DZs0IKbbX!~W1EbZyJQ+MtajVxy()9o|4 z4X3Nq&W>f_-^yLn(1kc z5{w0LbKhBpF~mT#z829}cXnOkU-TU6i6vL zwH{sTZDchn$6$_>(+E)inhz9GOHL>N-ztBxRPd;;%u~E%%bA|9qaNTX<)2aUq`)8@ zCKU#nD8B@qJjC|MA|9Im@-K7hrgCD0y9on(xBGbZz88S1cywDgf4B{JG zF%ZX57>cpD3@uGVL45IKefZhLqY;XO?Cde3C<{faYZCX2yf=Js;o{z&;|Hft zukgBdKs!~ADH=IJAepPvQL|B~%)+&}ZnqP&BF?7ZRUP}7^}haMi`<>A$}RIj&etqW-w92%_kH8Dd_#|z}JnO+oL}I+}U(?0_q2&h}H=vcGewT zN@YFgx{^!feag2_PRWCyK(n#auEPchfn1dE;lzyt55Jr~reDwgWg$JUE@sW3#k9eX zVGJzMb>e9VNGQ(;wQd|fS;RjmGl&>zs+~@2$4oD39LYaMm&-XS;pA=Fp>l*8n32+n zisAi$(XJI#Q6^HPTHT*MId(Od%VEx2HmGOQ^skPU*7=m@z&5d6iR-j5&t7;&hXiu0 zRU8_?r97jx?%ZX|(#tOm-#@7H)2)!k2v4{6YJW;Sjc3`FIw5Q%&IH&1f|6SpO&M&^ zc&msG8COpKwB*w#9ZR0^WE1SuD3U@jue}^Yq=9Ca4x9O_1WRpvxPm6 zZVx_|{eCu4cu-NTX0bRy(m)Q7g$bo#VK+15f~hf>U_o(;$qJ@U*L&vnnbG#)rz0;f zSrTJYQzCHy$C!xyd(bizU@pJy~iZOlTdLdUwjx{_A-Y0my%*Waco0W&iz>MWw6%KB53(#38M3JL_; z?ICgirFxP5p`mFWGdJD4bSZ6svZwhh|Cf~D#2FYVg`$jBQ(;%GG>F3o>{?7LRpKm| zRaL)Q>7D-ZV_4%DkF3ls(>uPgmU@^FIV3Q1z}vhYmrf-TK*|w@(E=oi!vjY(#x`Yr z3|oKp;F8w|Tjg}2#IhJu3#Cwch>|jvD=+V~hop-`@@Xw1S{^(dGF_Q|Xn*Gg-mxHS zWv6oHd`U-6vxS3!7R&aUfN=MV%HGJkaCBC-zZSD*_~RykgCXf|d;muR&AdodR!#MeYy9)Q#(n6_xD(`cPj^Xo ze`8Ay?!TFO6HS`3lakee?Pu(Ep_p>PC;~4i0n0fwb3{l;@9g1g&V<@OF6{hmV|=>f zqcqAHAjW`b(;&bYEFSh|ul01Lvh3$)%ci2S5XM2YI!qW~b)l3o z1huKcl%R`~N+p;Iww*hvr!lL0{ivG~eR=nlJgzS!YhnQskcd2^3zu@NfH4w*VMKx0 zu8#gP{J6Dh%ZeZW0ZWYUQ0earp*}#bdM14xg-^Mg$F)F7?@uzXA1{7++Vt|-yfcGY z55n4XoA2}cw!}u*W3l6D-5hjV<59|`(7>vV8()_j1$gqFE%Rtnttjs1VPwwiN(!_WxFyaStkKCXD& zZ^P~EgFm}VzzM9XtK*OyGeY%Vfls^Gy|dr?dKRoXdyn&I-kTnSG5punO$UYMvj>Je zooCwZ`Eu{!f!p4P&88mlL3=BNo_saB&kOqLrT2%mks|}g&A1)^_o4{?HB^)u>XxeD zL-d{hlzSADE|y}_&HwbK-Qi>n)cwPakxTzDy=nISVoEGO8NZxi`noGDWn662!PSxx zh7ryOe!7=kzIn{xt(S4(_;u@cy3FHQ_;r8HHZ+8eGsZAap)HlJ5m>*%(FK?O*fw;c z=ep>vfeQXlwpBZ~+;QrJo}}V#-?n%PeX(@=N|tuqvH^GQPLdzr;a4?t;l7?v#npSh znLURp2i|juQU1203sl~h{c~rX`Fpv(!|?B}#uD=R(EmsxEZv!4pMTm^RCs}0 z-FxKl!9P_`U!L83nQ6=eC}cxt#$Oj)_t)cwjN8#D_wID8>c%3mP1&=17{#q4IK( zlfvj**}W~<@KbRyb->L3{wc~>7KeOi?kx%4^gM7 zVyW!}=28w1l&eG)2bQZSN6o3U_?xA?Vfxi$9V^dNvj&U81AHLLD%VJqYx99SlNNR=a#cOU`&E zsokDx1;RDS9+jby%u`j129@s2Oi88GVRCLPBzL*%Gmx18k8bJuzi4|CsHU!ee>4dJ zlMwY}0K*_AhXaH`B?%xX)+C(7gvk&AL9If-2#VIZqEeH5Fcz{ZEjLom$l-?1#k)a>bqW)*Xc@y=W7cTg#| ze8|j1s1zKP!UzV4|9leL+S+{1Q+}_+vARxix=c>)MM|l365s{!fCv{@43o(1zCe*; zj;g$|nj>H*R9muYjvu!|e2u~kR)3%;luA(s_+j!iB&K)>sZfh!y&OtpD0^9m+>cGG zh;cy7WDTOn^?F!}ASj0DjYTdLLJRsyQbT(hlzDmo+a+|G32jI$V>JL#$&m|v)imN% zZkmdV+i`1Um<{d-U?=H8hb0U1mV%5K};rJ zZ9V~D8-Bcd{>TONVtp;Yu`mEGOrulMq)JhKAn=8ugP-8@ki&C4fMA>)49tRz&gCUV z)^e-0`cvl}JgEno=g#Jvc+zpvnX7K5ELas=3{$~fnF3OIbmn-)#fIdRk%C}%y9Xi7 z8<{rw7-dMusNMZdh;p^U3|vW=HrjY%I4yQ>scSY{oxn~M6L}7FL@2c?)T^*`N{1+a z%2*vuH`TJWJdyl>Bz0ZedsPGr^I%hzo1n9(9C&UBwLPJ`@JJY!puv#0mfl0J4>vOs(GD zzS<6OL!u^hclB=n!e*F17R`~@4lPR^v1yF8fJcJxfp#2}0boI)qAUty=7^k8+t7^H zLs=)zO?P2W^??mEAc$8;!P+MUT%kF(%l(MGcMxq2&^b~fZ)ATV)r2&v^6yTR0 z_&UG{0#lh>gkHc=FLp@oU#H>AWWYK^+8(_bu~IZLET5u)K@>6u>UJ1K^LgU5*vXWN z-85&m25(AadoJ{{`XIOEy)Bs^9V{4xG3{K@=;T24^#hZ$=Mx|voz5$dkCrAB*Kv9n z1>$i!BKdcf&eQAdi3zsW#SSL39c#B7@KMZHHxYHjb(bavzyA1K8uNkSE|)GeU&>%x zhWHL?*T$O{pTLIPNR1}-zVvdX(M-k=;BmxYNWcMK44Ivxc)+XI5caq$mYmdBgkzG> zLl&OUhbaZC3s~O`kOD_hypTsmFiQlyQ5NQ$nASkKl4M{WWVZl;4XCNW&VqpN7Q*vC zS}jTO1KgRE*ZgM3l&<+yiP1^kQh$+B2$J@gJm8XHpvcJJQ{e4VqC%^D!#>YQ4njHf z#Ki6EbPxRKdF_~i%AnGKFAf4TCBSn@id$4-JI=W=g>bt>!fm98XOFouN8ry}0+vZs zhY!o;K(=f(2a_@54qkp4@C!^@JuQSua!@>Xbf-eGC|*v=nZlxCSSg&D+0J!zm?46x z;HW^ZVZ-=#T9g!;6FucUok%=SHi!RJ|;J6`{) zPXEg>=4#*U?+;(Q7vcAE|AWw1bMGz=c_+4hb-Gx#$?M?LiQAhtkkl6>+GnZ7IkT3o zQF?BCd&6?wGsxqcUB5eb$MpAA=j?3K>f^q-7Zk8ZUf%kHzxdCr?Vha(atB+P=I;IG zk3@|2{a0Ks1C0EqH{utC66@!3zHv;-L3N?$DX09nt6%8d7{!A{MU%2lmkxR@JL}9j z6hw6}*t-{|E(p?kd*}!ky?cj8V(*?kGk4p=FHXM-v-79@psk?6 zrs@Z#`cxR5_qSH!?EeR?#9UoZOVrxsj{L^&CG^a~6G_nzuSN>w3x6+rAAe|8Ht!zziVho5C+?PKll zv-*}>_gjvp3bk8^#@@eOD2hT=lXw~+`anb%?WVBQSco#h}z5diYX~UhS+_evO zy{1g_^L)PLI`dH2-udMh=G+lj`lE;Ho^O|(87!8aPv5!wR)%-OO-I(fp);pc9;%`9 znjP-CGE?VH`}N5^=Wg8|`_|2q|8vh*_UEfrq(x8HkKU{QnKiJf_1mtWJvMmtMs7%d z`}oc0Wz~wGy;=w2O41@j^Agw20X@9P-?-D?nLqjUA1aB7krzXC@BZJYBu=A&$;Tp; zyXUEXA0Hv!J~8L3>&w^;+f-+_LiOjX6AvXcY)SDeH25Y@$Q~1OsN!cy`;QLTk(*gA z-Fu`ZE9+-B1bY7IzpkITQ78Lk5}7GGMNd}Ju~$dj-A;-rSf zmv1}VpH%TW=1KJ)*(~yJ4@s$0OE<*l)|N;7x^Z;EXGiBcHd;pF($>$TE3LU-r{#U_ zwUPrJp6h`bVlREmcabN2UhC(a{ATGD_!(OC@{G@ws2@jCZI7k3J8ad>J3D4Fr^;?k zesW!5ni&ZCtv}WTp2~S>f`47CPnNH>nburKU*0ZR%IG#s*y^LqefD0uJN&Q5d-6Hw zYd44cO|lgpm-Cv6c zW4pKit&m7tys*>#i?m~BlK2&aP_i(6E%Yo);2=hVGI!0oUpM1PYNvbumesb%g1k2; ztQ7KOjWhi1xKZ9yPUu%}+&o$T8++t;*7x?#@s!kaUYdrW<@2|H_hfsmGVt;CFGDgm z#`ka@KR_<8cMPq%m3mB86+8A+1>p3+`%2xa1$j1auT`aw7{j}c`t4v97lq_4s6wpD ziZ`i+_dKr>@}`jJ|Dy(iB88eig#T5Txr8maxhX3lg+09Qms_&pqo3S*bo7AMbcZ+E z^4p;>%Zq2%V6MTkpv?;18hi-OF9)Lw*WSL=R>V5K`FPdY+l)jr&3s^`Z~J-Dk@cr> zDKoY0)K0wQJL$6tQ==|{`qj@K#w6hXKH1w@YT z3W4>)O>GSQAL@r6X(3BW^LMImD4r-UoVY0OU&#AZ=Q1y%lO87t(@)EE%=D_9O(Ok_ z9&1FXO~j%Hm$jzX^RyR&?kwE2>uCL&=H>A7+MStK)))CLO|V)g;?KEz0-wIR!QClO zpEuDn)XFGt2?%Jt?BX)dXQ>cwV-z0#Pt^m(l6E)Ed(8szsHOVmy{#F{v>`R^0^jGj zXw&T(5+pzR#*%0O(+#gJsx3OYEZu!sBGh0A&G3u8EdAmDGpW<{uxrB?N0&$Ycpct- zsr>HX+O*Aa-|U!pH#S{!^ygTIWpA#1CU}xPe<18Y$<(!Q^X;RHxC8DNbu(P-x7%u5 z)`iM$^v0)&eEFNko%`Q3yu1;SFJ?TASbepA|Lx5#{f)`5JUbG?ACuN~ zmF`mGWieg07^<_VGkhdh^Dpf~eyzs-bn8gMjGj)9tn;>W9rcxc>KHn!VUkG z)95@lUe}a&VS7e?E>0_Q*=aH7kMgG9eLOy+@8NIIn(x%QQAiFPx#%{S0^&_m)M)Mb zJ!g7GSC^l+=RAM&naA_)X=lIkW#b7NfCq7GJ(=DD$ex)YV(|>6Tq2cMk8BdKQkDvy ziu&j|DTCU}mp>TK#!MmC19wh_uOPU%fJKSbxRLc_K99;n3X#RrUcQ@|q*Dv z;q;}SduQl>_k$ucf$ANozIkAB5SfrDNRCibOK_m;q>7JQYU(b_DLpG3fS@Yb=Z4Z_TDLwYsC@HK#6^=agL>jkC)*3c& zseDiynfk6}bBJ$m=J!ImEmJPif=;^Dn|lREc|1`4>w^r@2THykz0>nauzilmD<`+@ zL)U|dC8kUmRpMPRk7JL8IhY;vVvV@sQXL^}Vd!~CGp$Xu!dh~&y!h$T^NiD_L3dZe1~y|>WVKlSI(@=?YL{r*#pXOV2=ag z#U0)ZJT_CnC`P@7-Xdb259ZyJ!MgOFNKh4~)eOAfvU$a<{Hbh(>0`6jH^bY_ipwD! zgA^EW+cHq^(dI$G%<2D=se3$bri$ z%@9(jh!Fhjd>V4wEV)0Km7Gb|PbUX7XDngw`lhPQxjkJV;+3)R53N*8rL0d|m( z7l;OKwTkSY5hN;ku<9(Vtkl|ZjHO}VUEo&2<(VQOIEP>gv(Ij~@i~unTqIk^hJuW(wHM4Ew<(`Ze`1_@mt$*$*jJ24kz+Q_7pi7&aN3m-= zI0?YIOp(CgFBF~{|9;gEos;N;9$_n5r|J03Xjp7;nQvBCrWTAl2W3#YG#*?`4s*rS zym72-hL6`D@lg+hw!T0!?1N!1e)!eTKbH!cHITt%<@DG!-W0nO2FTit;u~&0OeCB4 zShR0oSI>%8QLeR<=nn5%!?R@Gs^BrKxLpcvg90IoBA08x2$e{5VukCHwt_o7k>?Gq z+JWVqYn#uVD#s`)K-s@VQAi-nTSV#V(f}J?TX8Q#SLPT}mzWSa@MdGri`Jajt%K7e zJdgWlnSsL{%p)-XI3C}MV3sU646zqv0Z4GWc4PO~0>iG~B5d<$T_}d?DOjvM zlSh&AzylDB17uF2gj#C-mz-MC`0WmYE`_^te9kKSExVlo4mO?TVvEgwi;Q_^0IWl+ z;owqlhSiNMV=$%!tbn@TpL={^h7m{Q z3hg`Y#1PO)qLSsW#?tL?L@SzHNtA@(OdqUPYrEBK#c??do&uW&cH?yEB@_!YHTvDS zi1XI!KDSoR=+fcwgB#v1nCsJ!&qT;cW6R}$4P92QA^d?$jd{YjX${F}`Ec9r~W9cF=b)oWn5 zx?Sr0)ED?SNHet>0gKX=0l*r$Q0M_XrP`gy+et~LL`F&Lz~v0>%;w1RC-?s>$8#0+ zF!1x$gBxG&&EUv+P(iwcECGh+^-6;S94b!XHZd4&E6RT>2;Cvrq_xJm)^|Z*p=gIp+pFwbnx^6{b}NDPWM`crgqWM`_)x zMqKW#mC9F$mJuIog4KW$FmP*F6gjXZEc{(tNvN;V)k?8)!1E4}NP98Q1N=276W9Y= zkfQ9_2GNF`wIV?seNI@<9l^~R8NXi1n*Y&&Hp~|WZwvzh6sahk3iVcM6~v)Ck^oYz z{Z-Cq9@?2rkOWO>+`upa#R~BF0+*(%N6Rx|Fc0sSkQEA4mTAsWyRR4&MV_apT3atQ ze>Zc;$px|l-$pZ|O(YD)z|)Coct-mz%#QaGsIa)qc9b<}Y@&?sc*dGA>kPbf->cE> z`N!2chEx`VM<JD9=!EVCx7-W7vZw-iplCQyr=i8=~1CIIBGy^y7 zzH8%Oi#OfSk$8@`6zRqzrUWE0PFR;l=}>s3?q``eda!8TDlss02+xRTW%(ajcaD7j zd8FIOv9l-4ZrKW9cYV<5)AnKgGKAR%`KQw`NgiPG9GnTSnNVU1cVN4%)4c z3!uFV3<~$=x#1>CFCn;nyKg`PUSoYaaI5)bQ`ZL1wU_oBZ#Ec@OAwTcNEn>rg0Ns~ zv5%k}uQ2F>KuEq+|)cWR#cSQ6dJX2kb|nZ&iXR zOxdxfCp}Y&e|UbYQWP$KadKDRo0nb|GaF-bWrR(COHR`Maqj1X`e^udSJ%H#sgF?M zM*z<6#MAu;jy#&Va$l7Bb8rk8o^$EWF_rz&)ol|F(V?85zmr?;w|{F+sJnP_+`=VS z10TIvZQaxrzI&7Cm!EI$`>1r-Ue0)PR`r(l3y?WXaNfDMpE)b~U`6EaHm^JBV+>n< znzuAnVe;F!)#7#?i@EH;udbOs*M7FR_uw;^M-(5Y!JX+lnsXm&#(#*AovCMB zUYY&uVnFAzDMA14)2#Z(B>cf7uHBk-j=1h`yz^g^XnQ$lf837jhZ`ezOk2@5ZAFR0 zyISg4s8B%E?~i@a^yT*}UVkt4XjShG){I+Fw)E5VhY6&gzn?bL8pR#sVVYifF#fdn zuDjMP<|l;ZFO1$tA5ZMZJJaReADk^Gezbl)6jzWjC*t(vzndw#e~c#pj7KGRzV$Ds z;s3m-U_5(PPE|g7`qJ~t@7KP%zp1k5_Jxpf+5wq9l!-1YL80{%QT>aXyV_j_p{6uxg|$kvIy_h`I! z;mR{*OutLUoUps*dE-@!lDuZFxqIS$R&?ioRK}0N$iVAkWeoJRgfNdl?SDu^Hh)C& z=$v?6xXwklHgB%J1>0FLYWRAzt^L{fdmh)upME}J+pBXIj7TZgA~s%)UgO2Q%gQw? znM5&_NQV4)r6MtXlu?jZ?;jp*vefZa&V)&;ADn`K2&*ibWsB0fXLjYKq?svc)R zAaKZIkuyC>OK!?gLY}j~gEq6UWnqkgm=e1z&0 z+ovXzV7HLGVybE!2Ybup34Da}k?ZISB4b$`vZC{ zw<9HOr813G#dGVVj=@68H42WZKYw2o!V=0^H6f%N;F(#F#>kr@7Er|v#Gt;|{&GhQ zxUc4;^Wg~iMXaK$Fw4jx#%yu!+QUBUDx|#Nh7@dcj8Y;NGJ)N-;zUJayL*g7JOBr- zWGgDDBTWJV$=c1#Oiqn5WGfCTePQB`a1U+<#YsJf;zHGvcNhR0T)|T_5gTybVzrxi z!L{U%{2nSNm!^xO`fziqD2W2Zs)gt{MvbAm@xp;+k1z|WuMbe9Xo+iL>&N@kJV~|= zHjhJmUS*5U4DPqoM^MFfYkxj3t&|ra?xI39l*EF$Kp)^NoETituFDQ*i}T3)$cVzx zvcre#E^biv2IbMy`RkA-9ctpHDi~O`=eRgz#8ktpJ{XYfLu8Uwm@=xD0CQvebUE}B zrE$jqI?y8&iN_%2?X5^&$J&B+t3P5RwiDuixrSWl1vgnN;bOheNo@=gLYJuK8d8T2 zvy@sR#QQvJBE}M`ItN+ev&Ui4SG=LKF_R&+t0NZZU`(%% zj4UNrcv=vqQ{iMT*C{U`jvCDK?QqRnLW+v*Kzw}oY(igKY>D<%LzK~glz9<5(=_5C zSmzX<=ihKl$68*lDI71SjkOvU^)$bLiVQSj)cb$-kVVi zN$rRL(P9eEFR#8Sf^1f1zKtU8ppF7HrOvi?EG>@YSYTVp$ztuugIUsaVX4yY@1lvH zPtZ9@Y1IPK1ckJ-gVnp6!Z6QW-b5pi{g*WSn16?RsS_Ls#E74`(O6J_r-E2N6rOFB|L8*|jAlmP!ng zxsAMY=y0lzQeAawrzBbybg7!XBQH{@fkw&fe7^`%1$mrO)OjyH_PN!T?q@tWhop`QzL&ex6c2F- z$qx`Rt#@QpO3E8&W)D4dvycc5u>#xe!U_Y4nG)~Tp~^GxdR0g(@g5;z>io6S*ZM9G zAR;vpKEBErVfF@PHPBe&X@oozazPm&_RTyE1t>|qyhG#ZRDTiH#8XMf1VDR);-)+| zJ5U`skhu%Z^$=G^tqQSl(Y%&urcJS`%KE&3ZYhHXlvsUGc&>SjFBZUz^9wbF?)h{c z!ANrdhFDGBhau@O*{595OKNcBO;-bjs*`T~*Y_7;LZ_%+T9prx?q}g;`Y59lj8vUd z<%&rcyetPGbFejnrplX44Fb=xQ@Im?m4F$mmil+7>oe&EJQ@~CaqGI77?+94RUrfn z54NW=;sc`fOQL#T5RYU?c0LaH_R>ntUa zS`(o(ry28&(7(Z?zp-&q_GLY!`!d&0>d?X+$J(9j7gqPV%kECG{ z#&h||C(1R%3$fKqq|+5Eaqe1-R!d#D+V;^r(p=;!OX(oYp{n+guW~T|t~~5A(u!4s z?3`Fj0VUTN838I^pCAN;*k175Z>+uBHD@lXG*WsLu*9Rk6&{AW#n zH}RtQlS9;q{W_o4hg%6#+xOl&Vt)(m-LRRI{_EMU-xu#duBsP4-Lm+b{l9(qE%p2# zYkvRWTwYkRbYAkx;r2OyPI^9V>%%|)nE%~}z6Rn9--fpreRj(49$QfN6Tia!+U4u` z?zsvy>{#pj#dmskjh$BKjW~F8wca=F#@>der-ToBwye=Sc8gtpudWpi4M7ilo!FzFj`|MIY}N~O?>uve%6E?rgdDWI zSR4KI%eDdCzo%LKk7)*hX@2Ig@-9*T-%7Su@9rnQeps?)Oo+w&#miNW?|=LglD%P_ zi*R+0P3-vk$nU~(5Bc?4&B~U}Kl9T=B5xm^ z%$@e)r|a(w#YYb=`O#9_cyRcOOO+wxKi_)l&%^C!UpEneWt!u&h1;QQpL_ovUDH2C zHyMm>j_>_(MEl3l?O;83%y_t!dg*Mq*SCK0V~j5+EY*!#;+H?!v;4Qh<0r??-?WJ6 zyk?bOisY(VlP2IiJzGnAd9ui?UF5Sp!ug`bJd#^bS1~krzXbv!U^4XK?()FzPR{av z{_X}FI;n}~*B*Cw{ygJxQzLF#{Nelpz)2h1^XTWh@^G)^R>enB8h6ZAC1JQ*@pR>i z&!>zMyR#l#anGC`xDfq$<;jh^+kU(LeI4ig=PpOS7pQKZIdi7(a>S#a=Zo&Pzi!)@ z8N77w+-}bcth9g6lkgr)4!l0rfFDCpTT;WF{*%r%=VK8#c2cZSoBOM@v3a7kRF6nP zki)5dRXy^%Nq4Vr*BM=*b0lusbx~cyV@{OXZ7c{YVih;a=5d*6gmC7f%i$832eKv@ zS*XrRb#n5VsAg69CJ-EZ*fI0s1(=v~VkVu8w2REfeQDuA5)#GaJ~p&I$xw?}m_|&=FpcF^m`rYtJc@DGkH}bib1imln8aZ7Xh_tAs&F7c`cts?AT5qy+%B6v&G>tnM4bF+fKoKUzql}o(lE|^zt7FD*!z6{ezO4u{v zXJh3>5;le^%EJk>8?3n1#7ZMUM$GI%&Azb^jq6*~l@d&N3G~<1upr@jg5|oc#Q9vn zCr65()-Q1;^w`f+T%lTWwFN;_Oj*ZIxKXQgntn}qKnU@QH~L+1uc8nI?lEAVs0tu* z;4A#d>#2oTb2t84m%t?8yCp83sNRjtH{Rm$b~2a<$f<)-N;DV)f}{9+0u`yBA-M8v z-&3wbl7}NBz-f4;z#+jGv> zjQB87FggT~n3ynR1|91FN(zJ(C_DQ*j^Gw|Z?ROon&U6~I*teW=a3CkQW*@g0mNyM zQ9kYu)h81j)&)FpXSl)*`s*m25n7W9lam;TfdT7bAIX3b-d=R0^ATu-Kh^xZ(^&&^VT%!DUXuqK^8kGQAvd z;T5=*L8Z31i-cSbnFsRBO6?Bp(>GE;ktj^w^8P`IF)+|Co#*FbO)TDdq1s3?Slx*f z6QsgY$tosrb&0)jt^9P+DV>;4R=TC<>a-B}S+BS=LC(&Qp>%s)QP{3j(`Y=;)X&GiXS{{QBG~ zIe#oPm&{^LVS?il6`1F6 z&{{>l(ZrxA41l-QxbiqCGcEU&1NJ)%mDwofOHS^0FQf^49raQAnU>m9!~{m(E4ab?*lfjS*mN1I%^uff@-sZT=J-Ja;uz6)4q1=}NMKQJ0|dbP;do`ZG($V(XM_ z0~4@S6*j3{9*4!52y|}RDr|>M>IVlE;{AAu3t$j)_15)VR~CvE!aP^d@dkPdofP*6 zjXbd@Rgq|>b8+_~&UTq>Qm$x$Z}{YzcxILbH#4AG7)U#)TwEdLgI$NQkZl_)+!amY zi4#3l?g62Wh1qg)gV;OM1l3~U23%dqokSwN{wx2llKre{wzLVvQYyZ6Nngny6RN z1Bt#PNlUl&=b9^Kzy9n^-{|<-H;?a{b3WO9y8O1su98!8Ov63HeMde2Zh`gxV@y-Q zm|pFww-P)4`JL=ANTI`D@}bl$@QgwAY&VGePe1=QsxZfttE8S;0 z_Ctkcz5jE@!s7&=)J0Q|zBv&5XihnCd!F%$_IcWHT^(Ul&)UbnPxO})UFfGhGuL$o zbTmH=n{y*|_{+oHEnfOXbUnS9u=v)k=Iqv?Rn2KBu`eXg)}11xY`>cmXV0BAZo%2i zuA8xs_V#tWVjqgOi#%&)$DL(JF86KDnk@S(;~X`7)I7z%HYEN(O6+_8m|gJ4D?aYA zl34oRX7_T=kr}`4>mPdVc{_e^Q7BWa_+#4K86}3v%KjQwT;brtz-jWmyUH}1Ho{9(LQyoTYTKd$-JE!RW;#g&xle+aK`?{c|S`$^kQ z!i5Q9uZiRboiDpyWtE(HKwU0;^I+}=)2yS@(q7=-yYx{#H5W&*eoOlE_kLm$CqK4^%WNpdVzp8#KpzA~ zJKxtg%RuV<{68%-as|z2I+IUVwELh{9Tv$ zK7A@Esq`8t=kJ;e2a9h|%W=C_;|*nI2*6dM6iB!jz=4&_7a52C=yU6g zjEv3c*{HP-yc%#OM{A%C1v3`6+cHHAC#m2gS_1$F{4N8 z>zT3Qm#yEAtG58r0fQ`IG4KvXIhb*IEWlbRxJr^~;?FI8I6RmWd20A<;P;PTe-X}L z6iOR9zNGp1*?)LsvRX%`f0HGU=)_VsUZ;e0SwmgX2?le`}{W|{J zafb^chKh-<-0zo=mLsMFwmB~3T3jewN@^lsm_#_+>M?QQvFs^kWuw`j>9=_nfKnesgS;x773r@RC9 zzkD0$=bD;WA7XfU$l8mZFD`o4Mc(L2iH4+p1%(B8VL@Txl)edjq>qXpb4ivCg=YXo z=l502&F)h%nq&e}8kfjsI1Z)VPk%=Y*ZlBR@-cPL+uttMm3IXbNCnHbefHP0Usl%k zB-YSCsu6{Yy^wtM&HLIwtW30@6qtOl-23y^&~h{^|cB+PPyVOEpbQvnY8NA0xgjr?_~i|$a(9E zdUCY2%Y4n*zDAPNEiWs@XTB$;32xsRNPO=16%L2rUpRSUPG$Q&LK?Ah+UKlka|NVQ zrb;QsjS5zW6dk+n_u&^=7uJb(SmPWdpSA4*#jy&nm6@-e^JGYZg1du)J0pX8z*_Za z@VD&agFcNxva(N~exbeEc-m^>XU^iLIy9e4A{KDm<0fwmr*JsoDVzz$P};5I7iMnw!|m}+LOgAAO)!hzf4_5w zE11F1Im(PEp_s3jpfA+nhlyJ^NBJ}Ca%2DdNh6MQ66u~BVR7<}6O)%+KR7vtAPzQ) z{Ncvuyf1#TzDoFN{-L6}&3}0QQg*fL$sQFc`w11R0|0W(&@Fa*5m-TZ{1q9{A%8lMWq@4)EMp+*n0$2B>d}k>55J`8*Um=FE8Cs8>dgv)B{Pn#uo^xx z!?<7xrg!k@T7i`=xAPb<++7nE)PL#SzO5cT16>0@Xm36LkhS4(P=XYkxP+oG0O=M> zfkPEV!DCV>HHeD4I=q=lqN~^1iD`uLv2Hgt7bcV}55|KykKM%=T4J-h>{78wM+m6U zDar-uvkGfQl?@fye4T^_C6F)pFF8>NWMQ14GRi|8A0_oi6I)2n+*rD{jL41?#Sb~u zH#aO@sZ45GqoScA(ATq6caKUC1$9R`aGm=`0#2nCHYTMJvECZA8kW^|7LD^F1_xEm zuAjbp)5W_^{9$cw+m`TMg&`viD>&A2+r>oje3F<;%w7K`q<6Bb1Y=WxieD;`CcxSL zZXJTE<+PijN7^GvjZSG>=C1oGH(~PgsD-P~d!{)P`R{G4)OvGvZH1x1d?;9F^f1O} z2J_D*`+H*Q2!fMhUfQG>j(pMx3>GouvNLcGORT}y2!*F2D{C_M04&r zq1t)henhdehi3o`9GImgvFIjCf~0<}+s$DyYy6D3Zch9(#^}td&-QNePu*=`l!BeG zAU}i_Vjp2BMl%E?9t65F)%A(ZrMv1jzE7{t{Bq7vjLW94emRF~(1V^7rMK3bXL5oe zdj^wJoRLAnMOq;^3P?72V_~g3;6WBfT@$<8ejwn>uz_C>{!y*yjf|TcRtr)X-KysMUFQGz8Dl!+% zsXUT@V%}>y;m^XL^ePW<|Gw$um^-;qJi@;9fz7ld{o&b)K7K7!EM*Bwy)_a8P3V2| zZN$Dizx1|;@IbSDusPlS!;GB^V6Py&hDL$87;}NFy?^l9P4nIp@$+v&DseNZ8nqQ2 zuOCXeZ~}X{v9Fa*m=sLlPn*=S>GKVLJ^SmN{w4z}m$D0v1}tzraa6aEz$Scs;(ZD! zA{eY18kTn&iA7Q!heJ3^9{k<9bpYGO-!CEfv-H)SABiRZyW5 zMco;H_4CO~=1)fh zzhTU&Trui2doN3pv9aZ}?%xB2{xMJy80gv~&X50AmSp|^pe*?_{kv-P13=clJ2!FJ zhmLiQ8&|>@&q*R!FMqyF%6X<>G+o@=d5ZAIMECB?_fIo(oSy7DRZ_b#e%tiS3XOKF z`b7GJ?bWvIo#j_PAKy-2(-0)-82d#i|G?{=7qS|5PCYjJ#=4TE!8PX|TrQjC%5Q9P zUp2gH7IuE#qa^>V*VC#3;qWy<@uE&r=o=d14CX&BqEYPdP7L>!|S zI=VbmC_(|BRj!IV#OExAX=G`wh-e`-zI^7ZbyILvrVJ3lM>(-5+C)A)z7 z>pzD&Pw?L{Qh8upciXt{U(LJmWq9)P>CdQFvW{MSvRK{leZcfR>rVW|=yp!7*+4I9 zS$m0n?sDDv;4@z=E1#U)l9^Uwz0t|kE?UXqetN#LfE2 z4XmrJocz7NGfx4Xwh3-{B_g|YZF+ipAp@%Wz^p&}+g4t5)wcC99mN?}W>&FwjmYNn zSi2gZP`YQbt}DKoFlBDhOxJ{^*Y7nXi{EcZIiAc3l7{lR2k;ndjCvV0xZfP-q+YOeU0-G16L_z@n_#x_bU#c9O08zJ1GYGi7H!nUc9+ zX6W%FS7%_6Hy8andUfB-+nFdsShV39>@j1@h?K!@E-62LWOq@-)AO?GNHZT{{!d~N ztyY_UKm2ldGV9v9xsIl~Mj4NWH|0c4ERG6vtV%XVF=q>{88X%jMWj{p!SGj&z2w7R zzJ71d*=_rO`f(@``%>3X$+@r5q?a0!N=p9v>#ve`(PhoswgEep56q;h=6f%Lb5m|? zI7VB1vmtz7pn_9wa;DiY12xG{`$s+tWU*F<8cuBUWXl)74W8{I`Ah#o9i0DS+m}=Q z7Y|Ix9Sr6^*iCXGEb1niSK6IvaI3$|W8S+*@}#=MT{p9%^3=EmTo!lQ*N za@L;`-K@8V7za2{l9zrtWr8~KY13+R%CVK1SD6R=O1JQ@OrIW{&{fLn|8n8+6^Bcj8674P zc{h8JnGe3$x2c|i45fOc-WZWP;++)S3qbpLw4*XXR#)_t=Gw1`7kIImy*Q>wUFuA^`v+2@~>;Y|O(^L5Q;*;?$ zmonF_`*31*nPp(j@B!t_Tzv1rpOOM|OMGIAE%DQJdqo{;WRJm!Udanmx!>P?Bxu3d zR*y?fw|#VO13$i85g5#le_3+E{o>5yrr2iW!$&2EMjIL$df4;kiNjMj-2DaFxp@Bd zlDi$pY4s{q&EmgXL)?GtK_Ord3aPl?L)`Omvjljq0|zKUmoMsl^>$+7>7}Lj{lL9wYaod9SccwA zQeATf7V z!eCi2+7OZiDPXod3v2OtYXR2&D5^Ahurxvz;L@YGk}X49oXpJ@m&COT+)IWN9_eMY z;7T67P_L?Ia=)fv`QSVO<4jCpgp9qN(gV?{H@PGoY5c|S58T5|V`K-SCaM)>E~I{g zk5=DKf|c&`p}v|}bti-sN>ydiusLpW*^|$R?F7P4gfWg+7%PpxW>EV?m@n1MQ>wZu zwOEz}Mkns}=Qm=66ABBxy=g$(j+vNd9c8w;?E-fvknA_ybZ`-1DRHp5Lk&dXFdHP?n;D?x zFC)X5OdqpBZS=?UQ^G?a#de0GcDE{DkXHd!Q4E<9U_ilhm&H55&#Hoy!PLy*Xj!L$+v1}Qpl zwF^aB%fwA=%EFJa>oN)=L2w@`*`Z@g=)K8>h-?B`0^?6tgg>ue$g~+?$N;P%J~qiv z$f8GZiy5{#G3(cBx`HGc1Wo2MmZVOj!i78q0Qh)7k`CB&7>ubS?P5Yd7G_EDM z%NIn4I?rcY4#z0ygbCp&L1)-o$;$M#bto+GOa3>$MhlPA0}y(cAn3kiGPWnL5g}oI z1OC3fybzv$wlq0kr*iOF6f{?LmXDyorUPIyHc9(Sq^mCeJ|T#hC$ETJ+NjkM*mas% zHIFiIR%W!|Mx88P!>G*E#`Z!WaHAA8sZ-MC$rFfqLu z0L`YTk;49~C*GWVODE{as-#3GS=#mhRLUr&q4lWL#so?QVEkY-k%Gi#zL==W%b!sb zJcfEFVuKGey<*7?j+rf zT4j+6nL~UDpU40BpUNnFDT<4w(c}n9N9vdoH>UsrvG#~Ow3f)VA5|=yXro;f-0;$x8%wz+oook|DOkH_26c{sH;vKxN@BLe%DG@4e zEUyg-P?!-EjVlO%43$C{PKOE+5Q?J~PrN1%1x)=y{W(iCr9dQZ0seCU5XYneFvJ61 zIRMS4Do~;?M|#Pks54!z%dRRQm?<|@_-+75n#D~B4+c;*DuMap8tTWGlv-p8SFs`I zQ`A-9f@Z;K&YG$ydto$FfdlD+RGiPF7|06Dhak?&r=)o0OtCd|%OEF|srRKNwr9nJ zEAwb*J{52;xccgk*mgdXTjpcLMvf$yLr)M4j!o_)#W?P{o2F4Lr_cwLQtEj$uqrSi zV>E-JL}5jdPi8rfeIn(6BRQp^qEi)RPhX#MmfEwBK_hhN1Y;Pg)kzL) zy^@m^1q@B1Ateb15+yw!tQf#^j8K5lYqDWggUJb%;|(Me=lYVbz2X&8jFj$+UaI6( z@R*Rk#!=!3o8ju9zW;-?H-T#6{NG0>A;3%wm<#~}qRoU!OaL_mL~sq0fRhj~1XOS< zU_fw1T~!3hvVn@l1SL9Vd1Up6LfEN|u>vW6^wsRBnpCsgi4r)0Dk;Gn0acOn zU<(NmO0RR9X=T*J1OWE9upEk2>N^R`XIKQGl&Bkr#-&P#R9k}?L7g$seDF=+obi#> zf6NmBcdh<{LX$sNphwvUI2%J}7eEqQEiW`PquSj4z`hPi;LH)^Wh@_M zA7Ub~l3z_vrJg!!6{K`fENBtsK;2daanyo{^rLREU!HdO&xHG76+=vs94KlEr8-LS zRG5~HaCO?^MVGHGdI3}ctiaUp?C-8Wz5*R~h%+dMqM4N0ORxu^Ddef?ag_36HHV35 z0S!?GBl{^$MqY<6(9b(oTDzdlGj{seYGx)wO0_UcYirY=4Em02*%()eRlw8}k_u4l4J|>G0R66!+98EHsVR;6_ougz|QIn)(fEf-c z!ONtj#Rv{4bHw#WgT&4aVd1Fpy>|#D&4!5)gsSvH7|hARlL5n0@iMs0UBeMKrUo%| zf+Yz=**7AM^+-p$l-3aj#^K!@Yg0I#da^w%&ae^5KECVHR=G9PF#T^vcwFeRlX9u1 z+y-imwa9{*aAThP+*S~~&{%^|qzI*;n6kRwT~`|X$#MXvxnI99YX~Qj9=pIiC`h1d z@)J~*#31fY22NGKsWky51!fVF7*n>V&sQkCWe>arEN$~T}0oDAoZ{X{z&P~ZYaqE%(;{$z+Kjwe8AWN^Cv+O6=qho!)j@wTE zU)JzHi=SNhdMFVSH})&pmAG}EZT;%2CyOid@>a*C$FJwv-b`Cv$R0ic+Z?rhH88iy zZ!`1C;LzvtRrr}D!ws{z*!i#hANM{wvpNu)BPZyWZ=r zP1^d&gIW5|$1GR|K70J8ToZ8k@tmPwW0u*>FPgXR@0>tc#7~&;7w4Ss+>sX_4S>U! zysoJ$6vl0;oYf)ucCdzG&xu+cuj4nzVfPq;>V*6%RVCE+T_-uvvlQ0&|8r<%hW$?YD=8 zlnoAJo&MvSt?N#=dL-X!E}S*7G-Rat*)IocYn6D8-T$vETh@785Pk9mwr6)jMZu!W zGrxFycjN1?x`WzJ<^lZl&$djTq#FBo4o3U02**+p2Ke^hp?&{8!pfgNboq=O)BM}3 zA)d|aD!+59NQ$_AdCr(Sr_b>o-}(6jNmGtqQ-;6S(@ouoBU{A|UVU49BUZ9nCU?i8KNo!Qw2$j~c zSX3SCp-)WA@t=LtuQf=SD2mApycg|1C=iHjL3#ynuN)=GE zN64oG9zJXaq7m}-GM>C#K~#+>-u4&E>k}P_*4j3cVw_|qF(`|0k=(|&IJ9Mq&ao99JQL0Q!LVZWBrbZMlmCLaNwN7|9ilbfYPmZeU9&3|~7C3h^^Ny5z? zDUduWoruCI^D;G$ZE?^C$Z}CY!qmn)V zrKbZyY=-c$3|vfMHTi(e^~_#ts!p!{7`nUBxsS?Yp9D`CLl;$hDqdS6QYf z*-dOpo?A)~OcgqUo{o|FFv8Vbcr-HsO<(JX52m>AO&KZ~I|`$IDI5jtL@Pqsd{FvM z9r5|Fy;fhH;x#GRM%-Tj$XAbKFE9Rn_kD;S$Rb8S2+;5CBSe&9w_5;x>Xj77BrXvI zVjQ=*J5w=hsgS4hsjUtGK&Anh*4pR}RQdP)*|Dc#Pf4|)7u~3$_?cQo1Sf{ervGJF zaqa70&@8zdJwBr10a^~aR8FTW6)B*mT1NA3Y+Q7~eM|?8W#f8;q>(!J5on9GlseQ| zd%eE=?i)xgEoL8x+>wf*_V8dv2fzW^o!CV+Rnnv^#7kNd*#X)zRWHo+Tab9!^J=vp zS6VHJD1<2+ND4FX9sueOrMKEo(VOfJAT_{VIfeOrkGT)@({h zovE{zz~s;FGFO!GP$$CP0%^`>lIij@P-aOmP>Az1 zGMFf&I(S5utrSC*M0-wo{Cre-p!h^eN-;>YaX@v7Qdgp6kQpS~?g}kiEYS9Gl0L1X zId^9q<71X$6LBysP>^#N)^T!B>UX4tct(*3YZSKosAN-y)I`z6DOQzQ&mM3xrARqP zre+_v7o_pMK#d633xhY^K5)3$6X5BhVi<0!`N3>MU8_J{c&;wLt_kR)w}qOJGcQ}4z4{@XGGvT(PCXpdZgn1!kLc{3FR5*4_Rzg z@^5aZP;McbY&-x+s-bzzV{xYG{s3z0=S8KB%uKUs7(kM}+aM!a0#VWep$>1hvriGa zofTSz2-IN09h#{5)N-me*Y24vDm7nqYYo_x{wXcru0%z?Kcr0MTr-unJ)gg|jvM ze1C5}iu=J5@RY*W5HHa z?jzOwwQ5Y*W!g$9@`W0!!^s3l&{QT!EkmNP>LHvkKb56Q!Kz_Ll!h`>m8TMAL+wp2 znNH3EqB?_uQno1pwAqDtV=4(JBA#3Xbcuax}g&-R>X2o`WLP{KM55P-=77S3-{g4pt7KYO*8zPl-r=-y2!!uKz z$QZ4_oR6XjFu_}cljlDaTN1aiA=YvNz<5;{ z5*(1;xXZtBH@&b{*4l^Z{CrRcU&XSRK>rRlKR=P(q?7mbT#rfurwyiB$?!N;wL`3; z(KD*u27~(8o1rCUE-6>3%60l4m8R#QsvwJLDn$A!?V?ni%{M}j%bS+2cccDuH(8L; z6YgoDAzL7>(71J!FWPKOG@?aR_QEJFR~SVU=ck2(I>MtHVZ3tRF>F3v}(R;KeMR52^e%JrIj~`C-Y?tKsDIjPmB^ zKgJ9mAJoJvj*W=+R7grsTweF^yN<3l(aWGQ?bm{od!-*{((tcQj-y67kpJsNn&W?s z@;z1Xqq)yNrggVHIP&5qIPu`D?+>nw*s^BaU31-y{s;Suwr_JG&b$@%hsmT+x!aQE z1?R(WeP0a z-xod}eP{iVA=6rscRzL;KE1Zu{|ooN)cb2_v->i~s^b>hg3?b(0BZb128L z{p6J??H}a#<$L7EiBGK8*BsrRTmHH2%@@Cv%FW)l_p?1NJlJ{S^W{Gu?)wZQ*mQFTK=y9OYZ2+Y^DPi zRgEvYg$C*TgAj9r)Wv1DM3&Q6?-&wg`>CJqUpjCFEA)9%1R)J<&1a?b&Ir zvBGL*C0a3ZQp)J*sa`z%afUE8l z?`o&e@CNsd8(46Y2@G3^{62j(8#@J&c*J*`My!!t@)h>fr4!!y)w z{HVIL=||uDutBM4^eA?(=8lL$rF)i&fjq#YfpDXFoo-qzB>5aeroKf-+1EM0T%hJy zM!7NeocWpqAczik9tuTN5>~co>Zr$i?=kq1bYe)udBx0Wl3fg#c$0{Jcs_c`j>&Y^d}q1eUsz z2XNttnPbWjr`d8lSP9NbJSY)=9bWU9hD;D z$)oa!u0a5bxmSu>`SaX#E?y=$wX>*`;YDJ(RCBdsFddGk9F(zm1hu4NxS9U^BYJAB zknK>?Is66sag1WN$tZ-H3)9mB38xpGN&$w6V2K-p;6|>R%JZmp?hc1(c!0`M*%3(B z_GugdwT}*f3)odeM+~M?I`FuN(sVE2oi=ylj3-qbn<~R8VlrGLtfNIZYv}17fv<-w zH3?Oij*hh?RzvCWpV<~(NOKwhHy+^gP#U)&J2OPC$|)iM$J`M# zls?-Ab#^RBLH$sHqlu!ydSwXI@_K(SzCIKxhche=NW|brDX6C37|>m_`>3U-K4(ca zQ_P^+33cp71oyzG^na?HWuvYIv8r10EV^Nq2V=-^d6)=w)M4+V_XkxL14fxEr?4ubANqu9q66Pa99#V=%|caIj!C&+!2jg{I7Ft?vcL?9(Y zU;)*69Re4}R|^thE`ZXjGu9u17M&!O?DDl3iMr)8Cb2EI86BRW0zv##73|>Jo#`J$SE@9a~&F<2hxpk5|w!3K#9CeNq4u&+>O=3 zrrCLsiQjT+<-~{)vOsB*Eq)B9<+;7e7=19lwz)M!BE}lcNh7&}XQ@njmBLc#?c>GI zW#W{3__MqzTfZtkuxO)RV|Dga^$@1Qyuw&aL^ZMZ6j#A)Q6VAAb+Z>1RuN-LZI@bKmcJKAP6gH^df z(kceK8ZSN!b~$t3)jdWDK&IbQU-TMG-~(aDwvFRLcj0s%pBP zWR$}so~i&-pbB=0N{oawUjyW{C|c(kNKDF6z0CokBa;2osCR{?ez|{H!Ax$C9X+q- zC#$+;&S%+TPXPW)diKRBrDy&;w=O=TgdI(_J*j&9Jn4t`y_%Mt5|<9Q0gV@5GP zU=GW_Fvoam`(~Z_t?$1LAulJpd;Fxjc=Gy+i!Z3&i@L9`-s%qgVaddtICKvEnb%e0 zw>ojhx6Dh}>UZBRlD!PPEVrx<{mkHdk`OQ9^CYXT#4P^ymGWfP#n*#F#3G6R_xJ8P z7cZMU_qjGG>)QOgm}xF=v(KGlejhJ8((?Vn{G1OlRsAcb2~c5a;={o|4Zc1r?}6#`oB{frXZ4>wB^d9152u&Shv2DUQTxwxx9<3Ot@_TV5!2pm zJWyKdnvFp9k<+Jut2aC>3K@NOR^ZF)AA51+vj1eHI`))r=@rghvWER#5ASSb)=NWasUo*pQ!>tu;>}k z3%Jqq*0y=bhn$;swn27iM1-+dYu_#L6adP3w@OA%dnmS|L_9}76F}IcDSRT{851dY zGp;I1n(H3K!}vF_LqGfH4?$yV*??s@z$_{;FDnhtRR<7~j8IIwQIIwhEv<&sNd#pS zHVH87ZKM5oV{AoQR+HT4T7IIftNea_mnSemQWFNU+cqNN5g_3QxE~;1 zGv(3s{pl0^m11HKoGX!WNnJ0)Sf;oAq&Qq#9fBwMVfZ0s$(MU7JBo| zCEs)d?ky&1Kss>};;NXqnad{V)vSRCTSa}z8AnxP;JTe_=^o0gZ-WCHBl9H2sD_db z58wbKV-wF94F$zc6w9%ogPB+v-h$-f#j#@seR4Hj=|eBn+Ku2cUywuw2?RueT%>@c zmCfYqITzt_tDfVQ#}Eebr)%vc#m?$Mgqe%r${Gm=E&+&6`GqK)zZmDt*+@rcD~1XA zLT?g5MLdhy%*Cj5C0G-SN;onJ4Sn5o9z^1Pk^~d_i_c9Z!J%rbIG(Dy<7uEy8PE1a zJLV&L_7+jK^0HS*bzE{m^FaqIO4)cw2r~sLcC}IkV^(dWH>Hv{KamxZTGkE*b-4Vp z&4NQ}K}l*spp{y9ah#;Xswj;J1+&qoAz&3=lD85Z@U754)5lip(;z|dOsZdQCTv1- z!K0cjmTxmdLvA%mIKUXaa+J1X5rwVD3@SB^;jyq%oNggQac6y%sIyR3R}@e#>a!Gx zOqjM1g19O10zc~S@svs2SPrV9Ga93*45Kd0+2;=qUNZ3e4a8p#Xq}nEUIXbVUXVj5 zNe>OC9n=P9Y}y;F?%86fQh^yQftKxa^oi5jVwDC;%!nn8Focq?9+X_!bL=R@sL0*U zPn<6Z*%glRXqj*j5fg}3Q`chB-4Q0+S*|l`Po?aRi#cU4nIrAFnywLNa8uMAT+mT$ zqRazOlJQKArKTi4v+PTq3~hH}a#Br8kH2&7&Ie6a`JANh}MX=_^GPCDp79 zMq?3WETk+Ej1qJlqE#hpk8zdsiiik*%3WMt#w#+Gm@-ivmc(IF{g_l%uSV`K8lHF0 z>(HfIuJB?pQ5s~lWr*F8LQZjw(#)t)Dm{4e7KOBtfb~t5dOLsc$*mf$acLW>it+)I z2}nvZ)ChIYRRSergd6IzqOxP;5z7o*_Cpo-4jG7qsWGd7gA>&pu2Ll+gXN0sA)Qu+ zB%lEQkquTH%#8@bvXoXaVJ^mc%qUL%bAwRA1@`m1v*m0$s%J|$6+dX;t|wMh6o#3p zeosiOr0RdgB}yvyM^ri(c4KDduT|xcvX&d|BX>Bdn@U&&HMo*`x~)`it~a%OVn#AN zh)(ZjoSr!&-8eEFw7Ae*s>8nkO#+iptQ{n|Dge`yu(KQ5eQ8UZ=O{|rm0f7E6h*-T z9fq}81tx$^ZKG15+0~wXklVAA}+T2acBgI1$f|b3`yusQEEZc{)ml8w|yW&T!Yz70#pL3 zDU-br4qSy2zaWiuAFbG%&oql~xAYrT5~7F&b~33ZQL0yp0ji|l>{ir>^$(=Aaqrey z@|m7#^O-hSCq&gTc|@=nx71*<0)PxO$rKvKy=yrplV)alxjo#3YX%TFRv@H$$D8;d zrZ*x{3Sy9V;y9g285Fo@x_gb9M8IZiCtBkd%Po!}EEcLEL;>OoR;5ANIcCTUH-JVf z{yBy@uJa})=Z4xPRat<2pOo#+(I{`2dGT_RYZi#h7!vPX$&=wDbmD0seXh(&(I!Hf zDvKq`O4a}SVU#eGV=$ba3lUGuuVaxEO@?xF)zIQxNTGl?3Q%>iQpIG(qMQ&W8q*I+ z3M0Xut1>0`Vt^FkW@pi+h_CLSB^Stjv&^`YOdNLLC;Nknm|IHb{_y318 zUUg~*J==TY?iQ?<#yzaszYk!IC&c&~9u8e*=qlY!mCo$ywyt=-9KkZ-E zgsPsll;1ll-5l2%aQXj(Bf;jc>KgV}-TpbsBQDDHC(@@}_{O>D=6|^37f;=&Tt<7Y zRd4IwHBFN>;;CMiBfJqX28sTi^%7mZ0jd8sRKGHyxn_Of*iVAp76e8;)ZWsR_+*X@M8oG=#F#`#b#?Ck7>>(lwU!=jgaBlac)Y$Q{@4`BhbYON}kl7e+8|o~e%eS{HQD zck^fVV>yuQ#Ybm1?!LCbZ&dPN*I4zi?ZI)$uYYg9dM+fb^RN5R1#QiY=PwO!Jr-UX zJ-lEJ_Z|Czwl(Pin9yEG`c5mwr?HNd(VW1^daS?0lNE{kMkxO*xYd7Rl#K~YxBC7!2cu5TxD6mtZd_z3-kL2MFn+DbKkUJcjTs8CHkr$g++<~r~8#deN__eC;;I@!49S6bsql zpU28%CA-aBkb4V$ouOA34|F87?y0u?+{8M)!g zMA3tsFYU8Mju`!7!`+4U>Nbv`ZVvaPKIgs_$7(`{vQN)rcb;5TyK>ZEXNsTiM3Yb9 zVev-D&HC`;r;{&6TfcutG0u6>4xhI}-vnE>hKHlk%%S>Im>wetu~{uh)Zq|A%+MFX^1SGu)m(IXStYfa~Vzo*knJ(d^CK zHfz`t&2PIY-&1n_wbFTqb}7>5UF@!tje8K$^)DBLO_JRvbG@tO4CiY8XH8N6Lsg%? zf6k*H7ktxBxfvuKS{%ZEaV?5HWW|K(-bphflboMUyV_K`eqE0E)t^brUL^f?Xzzb0 z>o;^qkG1zyhW*#g;9^A<>+UF<)v>HSTbUhNQqu7J&ySxi^s#vBmDBpALHD-47CIu; z!MkxAVrolXG$%d8(7vGR^38W)W@+BO*du;vo$`Xs$^1tQ(l=z<7I7P%EhrgP5?a?0 zp)T-^tZiA>^vff$tu}89R&R5k^GHlq@6NxleA|)R%`dmD^B#Y!yrq5CwQ`S3k9Nf! zsa=?Ut!zw2dC$?WfJo1+z^ag4j>C(whz0F`9v*+K;)?L*-O8B%($|+Z?sL}fI&}Ps zx@7Eu#iAqJ%dL%X|6K84v~YOa)-^s~?kan4V6eSt)VP$gSt$ocE}DVup0!waUFfs9 zN|e6!!0mnb_NhK^ZN$UX3pbUlnh$?|ukm_Vd3WQF8@X4vm!6F+PDy-Pc62HCh;8BK zAN()6+JDR4_TIuEf6BUVw^!f;rRP?;EY;Y~jt`q)-@i6NIkgF%axzcT%BUkgzdiUR{^&=mnkQ6mj}?}d4O|DP zal4hL#~karfOa1_?}IzWCIqbBHnSql;AQ~m<4Z=tH*Bt56|QVD^|HF+#2l@|3hwlJY&VcUKN7GBj=?w;h|N@8?&1Sk(?ASf!se2lX6RCq_=cDXe;Kiq!|wji-1 zEov&?&mVBfjYNxzkU}bcUSnukntIUt9v8 zt3|2@(b()2#jFLPv@LRb&c21`YHB9)VSe^=Oi2B`MPK*VwvU#&3W zs$$sIUjiS$?LnD>-DBUb43urX716EMvjy4g) z$+`!Xh8EC*GG;{tFO1BVO~r;2GCd&#@jNhJVGIX{Kwi<2fs+;&N`la|tbQ$uClitu zZi}agA;R!LDrqc(McCZLZYELR+)kQ9Rozi3Mt+OlZqT4EzzVSlrject5K1CKBpcF^ zBm&j9>*wKGd?{1a2x$l=RYIZW%tCpH;#h`_9}vJchx-SOud@^j7?KkI;0zYb=v9## zzQSV2l%rj!5Mfd}w$6HX37#qQJv4T!V38NZMa%MxQ5Mj@nvP+XLb-xL-)n3mGA2;GX%}@jWIRnF zn^a&liu~;qp;#MP*qsKBS~S3*)z*|?B1`Yh~MDrL5UU01BVO%4*)zf z490JrDh|A8+ahWeCht*OT%_&2tT->(epj{lTKuz>*C{pO#VgHq*B4hYfMG1@)~;=$ zO9n_zG%+BGbb{RR3I(Y6&B}~pNyrp?aNY&}q83#b#5NYu10gS<++S)GV7aVDJ2Cqg zJ+3q@`Z_~d9o<3_t%RjTBVYjiC|XV>T`0ne9w`lW$7%^)+?>9Y`ZSn>aU;-jHB3W@ z7Frj?&x@dQMgG!qRGdK8r6~{k!&69M7`T=lD(z7F&@%?nefSwvwMTB`anXS=MCC3u z7iVU=(=dFqJ6ozRCps~uQ9uI`H|nv(^N2o7jTyZ;{M311QgcHHYf(rCVL*TlF0~pn zi4}U2z#}M>G&Jw#GCP-vjC=ysH{1DfltHW9AqCYG!w@CaY=Mz!LF1iL z{h_Nd;A$x$m*#%hn4eHv>OY?t>WjNRvx4UQ03~SuF+$9nxLUeyDncQNs4YjXUOH=a z7LfSQ^Umgs_H>J?-8l-0PF>;tChS|M&-jcMgJyQv>#HDpErYx9qr1}iBQx307f7rh z#7pVNm!+;=`1ZwrXur$pZ&nZedV9jcnUw1p)s(vk+OV0Mm+*MZ4H~^*!kxu8MTPgA zm!_r0G2n)`b4%w9yDT|YLh*gO79U?!(!4~k{To;?{)rv{FzPFmqwxGst>_;kM#J)3 z7k=1@to;4y)zLX**7CQe(?6Vk$zeYC^v?P1PTbMH=7WR#XU6m!Q}Z7UpE`T=!<&14 zpZSfcZ()-D*7YdLtL)^duIu^YVadrYhn00NA1$X9ZL5F_$M4y!&CZ^EY39tJG3%oY zqXe--3>)nqVq*U*CIuB!++1@Y?GZJn__E2s^!M=0|F;T|AMusF5nq3{ASO@({J8197ywCF{0s2 z)WfTSshsj1!qz80i5{1p3V++NLN+Pic7FWa8ymaU-g*2kWYhBDvyLC$`sa=v$9L>F z(fhIW=XdpU2Oa7<)Vt`Tg*&Ewp78baQE^KjKmK#lflmdac7NQyHD&LwQznO=beGMK z7rB}eZyg9t{JRtdto_fH{GW~4kD7>zIh(xxL4AAQqdu-S@w)6nc~@}TlM~Orj%aMz zQ+2!PuOIhB4c-3xoyW1CGg{BEd22%5g!b{IMU}u(IHVbNLN$UgWaCPd=t5z4iRHOB zo75RI%uIRP6&`8tcd65pT~Wmy_rCLUhudy<7AVR!(n1wDA02SPD`!{&>7BXU&2y&Z zuuH>6gzKfd$svd-IFPnE$fc5{BmgL`Do+s6m@2n>^p<8@Hv*?B*`|0AQEP1BSSEv7 z!X?W2T}&PDHz5l^7<9#>IzonPNu)H4CX+Rewt4c@SlC;$#^qq6agdb_d&LH>Rd_sU#lqleXw+FjXbf(_Iu& z>DLM&d0|HMh(c^RDJ@eX)uLEdbw6sVBw!!VKN_Ww3PdlGsT?MH{!q~xfq>Rnnj2POFeiY>rAnd@yx#M@dHo!>}xSRB|vyb;-drJqSZ&^UhaNDPxKS zD{xn*C5-mR*g#u9*ijiqXJdY8;UWWQ08!QDtBcQJc4v?npp;{cofKUiiB?;PUxER5 z14I6kK$9cmq~?){BAt)+>}`ySP8WLxOv*w=MPC$l(PVq!4kv7 zK!wtlFe<@76@u;p!=kRJZY7W4VabQ)Nr3H8{ty)&*IP;rM|WjCZ6384GyC-B$ZN`6 zfQ0k$yn(tca2p+9^6HdXUeTe}vA|Z1-6mwU@bO$SkmV%{3~x3ldn^MzCOZuXoFY=D z9X9i&4!u3X(RNA`J1urVJ_z7ALGGUAKBzhJ0*$H@Alg<{oX%c72uqhw8q1ncO>-Y6 zys$KKdZjR;M~dqL)JgVQ{#`>@+T_bCYB!*TR9#ZfPw>X#G4(QTh8)&)-qGC@G`P*5 zlmYYAsz%DB0U^ufl`TA++gyp3;cP3^-#3pKU*S7~AYFPXD60qsnR7hH#!6~dOtid?1ADLeJI&@5$#QK)^4>I)lM2VxXY{4oD5)D-_Y4rQ1CW42Zpc z7k-}^z?KLt9Wjt>xhz%3F3w}bPx|cyWi{WaWeAq*YeSDr6>UPd%?1EpAY#!;-shoe ziZ&RamUrj}g(DOkq?r9ln-IB{J|DEe07knwd#|qfVRdvK`3Z-!g&Mt^2Laiy0$f)V z+l8llD5!VeMW)>4G3Sd^Sr$=+j;zC;pqRD}!w5?myIO$D%XRFkctpe)OLq#Pp3a00 zT`UM@Z56d5Qm0Hu_3xlAsD;NR8C4n+>Jnobb$YAo=$q#sj|1UhFq_*)M#pfGnd;WzHjU#$b z49>mpIoR-L;IU7+q8<6*nY~ke|2$A|V%zTT4#Qt$9DkE`Eq`zH5>x|^wK~o`KIoWm zZ?xa$(-k4-r@xY({yx3^x;+gPf#WusMuY_Xa4@kut9{wz@2-@byi>6AcxKvoXWY}u z?7vP&t_bk&3RDdQ&Y~>ovYsxWk7OOW_4r!idB(!^e23@t_FJNZL)~ZjSkD;cI=_wTCebTJSgSL%%1F%irx8 zy6MN8=eHjn4SvOKpI(0KZOs7hY1(0boL9fp9ee1k zy1;2p8oG4Hj7xq2iw5ueI|wRe^go|C>Kiom+hw%c{}sf&h2sa0r)cTpxmV{We|6`t zZ{MsQ|9h_kp8D~nUw^sU-MaiZ@N|04#3=#0+Zu>v$G)3&MW-3P?Qkyx9EyKcxa$kM z=kW&%yaOt}=#0ustJx&;{wOH2ZFP*_wZV6vg$e~q%Ugz-Huu+XaecztYlYkkOCJpU zGEhH@xw64Ea@4u#+%HcDo{N)w_xOix&o;Bt9(adxWhb4+Jw1Qp?b823x*^m6ws(nt z`Ulc&eh=w5OGi&4B#a_dqySX{CuUU=ByOMx zrK0q_{_d^M!CVmR24dSbiC@b285>^SDjbMRLl)@st7tvEgj2#+$2J!J}AuBks z&Jx_xgcDSe9xXPj_@GXu5`{zYqk8Fv%~M|YGEQ1mV=%6dIEf#?rKnD^0z5^D7qbaz z7nm9-HNGiiIg3|ZJ9B_x8a~w0#D!d--cnd>hH=!1ml&8BSJdLs3rtU`PPq}eI018p zo)K#*&A3_M5)>yfQK3B+R_TF^Chpc=)lsqk%ze)VE{rqJYqo?_qO3qoNen>gRuLYq z+7^_GGs@*p_-kSIFSer~qc7x-m`X`wlz*f&*3j>?y_t~Z`d3Fo+H$2DHdEvt)L`j{z&N&~iqZ2nwB79! z!FKM3R}Ru?>{AJ;&S#4FV&>{7h1@EBHlBrm1KzIGt^`J#s;|Wi7Q}b$V3;Xt45faH z7MNg+TZ1=X&BQ}=j}NR9M*;i+$>+kKYCU4Gn4K=kzJxkg_!19**G%uii`az&SyS}W z8T@Qa6&~HDR9=d^C?5_i(@%)wrO&+dEtv<;z7ilWmYe7S?3O@?K*9l|1c4^d1HrD& z1bMF*x874mayV`YLb-@cR6s2>OFvx&A*dmbaA7_pSY9Tqx@<$_7Ror-P_?GC zn-G;c#nbR<``nw*XRw80ewYQZN-NM465}bmfnM?KsMEFnPvRi?b`mZg$NyI*J zr_MeS?*LZQ4$&q!qxk&mGH0zyOVyK3C{rZJjzPFy;gv!JQj_+5Ln+w+^XW5`sTdzPFSZI)LOvQT%IX&me4EDvc+TLz zt^wi5H2M@vK~7!|sX`%Q5X;Dv7Xd_JhA~RtN^vuoBU$yXoaTb&pe>>$Dwa%zh|*Oc zyF~$6h(bA9sOZof@5{6%ofdN4GfSTDt@|ny90`JHI3n+#d9&86Te>k<-#W zfpDlfD$tuZ)^>5{LQHKZnuc@jwUAD0H{`7Fd>SJ#5j;RfF`uxsV8AgL%1%(Jm)YGW z(H7m^+i=w^_xo{v&j#^w5!JXnoeuQ+wIe&HSyLGhwG5)NKt#5HBTS&jY4H2;%+!># z4!+JG?cxXUs>OuegoNUJjSW;dSpKb9Y)d{=l7FL=FV^`q%JKxH*LL(O3uTfZFBE6E&~lAYDyT-S;#eq$U&JaTz#SO2E8(bexJ*{Os&G+t zi?UEYfKVAa79zkBKs71Q$&Ie5swZ1(uWF0Y(%vPj(Uv?HlTAvLxS8ULq3riwg=Ahq zLRsb#d74fZ+JI%MS3r40W^bq&w(_ZFU1)?GA1efX(AGS=C2-Gz%q@dsg%NEhg@ruA zn}_2WEFwxig3`dIUQ4G=>+`&~g5OnBzD+`7TgMWeMCOn!U`;%MNir$@pL(Gj#?=Z3 zy<-T&gq!r*;6JUS6oTQZ4ov}Dq=!%z+7`>fsgtaVb~ZoTSx|@;i~tXNg@`&=!}Hig6Pj_JN^wdMxUu` zJ@oweaK(d{&mJ`HJon+#lC}E&)BE2(e|{6N*e)H&R3x{j%svi{`Mv+6?%sP3&N?I3 zGD4s4y0KI-ZuH%A(NV!a)!aE+|HCkD+=R7bZy;X{9{=fIZPw?%G5>V(Ur1hzNdD=h zuKi+%q^EWKdAY}DY1YhD-zy#VQ_;=5x9&%86)t*yeu#ce@}aJ>@A)4vM*6=Z$EnEW z0y2`e@ZXV-IP>*alXjd#et$6aSDy)9?O*inbE`hW_VwS>9k<{hH)e1~(cmRRd%nt_ za;vlG`Q}~DxsP^C`fS46PzZFl7YU1@ zcYhRr2qW)bVW_Au7VwofXYK=) z@4Pcw-{pP!8E+Y!JdMLI1dFDNLEe*eQPrcuMv*PvVqZ{r_7)~G4dntG2Mi1v*+pmH6uiPP7np2krpZ_IV zRaOz7XaWBC^LIx$TEdmG4qv)H|=t8G4tKKdDmAxGJ4G?gCA52&mG-)Pwem0 zJ`EUT_!~j)_%DK_3IxE3r@JTpgCGyQN05GV6KQ|Ge)aVC`yMw#uPyEU;mp$8`;2dy zo)=uZ3U_LD$_NLW6JAG;-R;z#rkR=+Qo>j8Fp!6l@^WWEumDq`I2~6g(APotB)un= z06=k`dkXuMM7M5#CyY89TZ<-jmX`cl{_CJd8ix_+tKmc_uSRa8ZG&pBR<`~0f6?~t zQB9m}zyBlzNJ7BL5D*YG6DA=6)DS>Ht%f0BCIk!-5D+cFh~TM+XR9@VNk{-S2?qhu z0tN&;6jAWdY7x)^qN1V|4=pNMPi;N)pslU@?$hUepYHYk)^F{#*V_K$a%ti{!zA~0 zf3NTL`6doL_M*I8fu8_sc+fsco!vo=z@rS5;U%+*nX)tvN+WBP-Bm0ncXev237a?m zj*P2~L9BEiZ^UjY5}E)j2)QCtW5u`=tu`Ak!#x}pRyIzeCU`BcqcY)IT-*f?gyDA3 z-z&l`W(ai32!~i|q?P}BFt_!}gy$J439kN3cW*Y9EHjxoLQ)1=D7;CKmYygX=c;47 zdm1O-ODh{{7JB$&y=q!La1fBZ1c15b`ht-p%Lx8TeRM^H%<3)dXQ&&u47h=i0d6iy z%FFNsx*b#H`*PQm_A{o7Z;3m(5S0f949w|`DD{E?y{d>0+pTmv&ShAPaBr?a+ILZq z5GpYD)Q_)~?@-*#izG`erF4QSLQIwlQeF=QSx6p%KK9-P8=Pc9*=Nb8r1kD`ph-jN zLme!Z5vdWj^a5-GizKW10_1kCJtGxz=dU`4a6|X3mJ}bvJ6d>db{~nu#Ily4yMx; zeNTCj(=mM*9Wj!aO~M9FbBxEQp(qez{V}d5`8Y9D=@SxtTC+qw7c{HG2{#%>f}+~Y zMr3Iy#(x}(tXAolG%U*`Jv8IfZ3U7pZSO`iZxWp#Km?541WeRrco*EHeDnqL<&Bs` zs>Cb_()&T80Tj6EJI%6547i0S)PY^bfE}gF2U6sxT|9ryGhio3H|2#;*|n@}Go*rU z5}#5dg*HOSz%Wg);i$QKcgyFsENbW06OlO8Y0R0jJN#O$rG}Is3OuoF5?85nJk(~N zPduajrHe)|<+C-B4jz2eiSm6fs?tKbL!I1^Y-Af6&I)|V5 zv@!E+r+bAxZ(O@nrIAO|)8KO=a5TCq=Y`ig>L@C*{m;NtegQf%d@feHl%2qy=N>j6 z0sT6c-~&R{MFH%(4vswB211qmmi)h|#Nwy8U$s&9DN`~$S z2(qNA6xvgR*P~<8Z5Kl=MYeiN^jP3%-KXgkRNuhjbON{z4FclyE~5T-6tu+85TfTCzDnFJ#S}}J$MMnjWCptBnIP7U5$QlwI!8fIP4~>De z5C1&tA_tj!qZ-L!}PEIYL%Jc+Q>CMhHv$Y(9oGXBX=o%M>g{7Hn6DH)B-`y>PcbP>D zZ(flCj0N!NMz&1B!w9*a?rY4BPoOTk6PN3=>Vp7s2F4=9Og*rZ{Poc)La=B|#>?Y7 zMctV(Qs$N5K#?}rPPJU~a%Xu7kY)ag5#y`8 z_)JTYnb}JgWn0qgHs~CqJsr(94CDIU#AjMW=#VKL75FA(OxA( zYvf$Ka1Mpm-BBm$d}iC!3YrAT)q{~v;B`k*d2Dbpij&qfAxz43{(^muVbjGJvX40w z%j=zjdiI5^&~YHPb=g@*ltUouXo-X21y4}9C;AdqH)Rs*=|W`+-r-f7rSTB46Bt-3 zZ3dO8;MQ}@QksZK*ya|hSL~>ReWN%}ufm){5vvq6^AR4HCg5;`=s4{vsQKN*Tx1l`?Wwch*BCnR85YarwOy+ z{oY9)!wKG%K=Jc8?VnBSiFMDqmVvs|@dJEnlrp6cET zRe3;??VzJf{pHG3DQZLHaolX_75${u)gEVxJhaRqy=t_SBeM#R;g}6!%PqU~9J^G@ zxQg#kk4I#9Ov%}z9A0&qE?#TrQwjQG4q4}k%e8H(puIkUuA$Hhb|`+cc*c=kT4wyw5W!K2 zGzEJ8)o*hkjsXLFOOq?^+kyv%SDK2ckYJ^vXX;~S=xapVIZD=mQj=`#(hYw$9NaFa zp*G-hAIUUkn3+Ae8ak?wr3e(|`6JNnCKqY*AY4tFwdJ(65x)pBIieMI?H1?~VU3E_Gq# z#WT6AlGQH1M0J|Jek&h7S8(CepihDlk}^W?ogdl8*zwutNutZm2k$u*&zlih7q;z4 zk!8J4V@FEw(=fg4P57!G5}k&^SKq8VU(2`WJ~-|J-w6p(b$n~>S#>*Spa1jwwY1YW zpItml-12YzdFfX3t*OuFW;Iz9h7hlh0~D_#hd0Mu>1dtc)7cWS-&936mwBFqKKO?b zyWo-LQR{?#=j)a|-0b`&iFtXxi|6A|pFPp}Vf#N)9h}`f%@A>p@QNS0R!AF8ndWr=e?gMUeaY-k zw=ArA)n4=254C$di|(9h|2bfL-oA%lFZl4ekHb*HiZgM;5s!*8zPs|Ea^V?r@3Vc? zea*=1 zJvg(X9ZS};A6qw1?)xMmSvj2Ja7mvKofW(kzw`$Kph_{hHE}gAe@8@fk_)6qzyCk- zU#Fjp5Bf<}+>q=tfu~>xMr-nW{PNGL1^YR#Q!;I>1@yeoG+={(dCL3?i*oORpT3ExY2^s4C3 zbF3fy^7Y4G@3}UU=lIi+-9+)qi=VAnK748Gqrsv_rUu2GIQa z&1ZQ_4-r+?lG0g2i^n%iALlTB`L=m02ZBr19aQ!el-SO#Sp%TBqM6o)F!7Nv_kH^u z{gqAE{R6(9G%hmNupUY}uE(ajhd~ zV$%8*`zB7f#hf*3DE*V5-ZyXFy#JdB4(!!!nXfgqsJfK@1hZb} zZ(>)r1q2+u?BVe*do{Cve9y9sIoGDVij}M=OHx|}Q7)$1O%Xy{?TwL779$z=(u!9E zwPkd4wOg>mKd=0s?&=0%=FMN$06+C%uXcI6tTN#FmF2t>p7lTcPb~NzpIhbMFG$W> zDO>WveOLX#6Ankx&w6AI+-L<6Mt=m)v$);3?WS!@H(<=toJ|uK26Ti(O{MZ2W%p9%cN7s_N?Mp&i7>zx^6& zi}2g<@hPPvlQT5%_x@G;kNpb_TS5zdUimZSudIy!wh!foK4|`0^uvoyZu1RW_nz5_ zRXr6gICDmlU2S{PzmpO6?o`#hnCx#h#HOeHoPECMqp}a{o;-dv^l9NI37g1wj~Z3< z?v}T*pS*u>&G=Y8bwcr_H-2=tJ+2mb$Ee4Npl$DGGi$%Q`saa9pFTbD#nO{291mK% zSAO>7TKR_QJ6g9My?q`2d!eoWSZEwr=)m3>Z_1Is7y56@_s`^-o112ht$Ox-E8pF0 z$nid%lDs46%7I(I9(=QAWJY42up7zxm=@ z!C(13?YeV^iZjbgtVScl=TKGx>eV!^XR}9v$qV{_*b1 z{`UoAQr|0jwpl2u!Y1cAYOc|ReeFSC)k=TMimEP81m@XICRrUOBLP3yjL7Q8>KUTY zdPJm9WjMEQulv5@mY4Zz>z+=s&iKRz+RABR zC5=#q!^qewO}8^^%(!(Sr7zjur=Z0SOBq^y8qesBRf_|eI-c2FwQQH0lO-RNeX=wV zvqg%`0qcz6wGL-fsMW*t?OuUvFiAfxE5O*>Rl;vF9mC9sO8wZO2&uH~SKcD(tL2%M z5IE@}L_}GcpN}G#eBdKT2BA9_J8N?v6%O>==^0S!{VrwF?AU`AKgs7p(5g2z9Kvd| zdQIk(YKaoc4PQpv9u;9a(M*NhB@OG`6QKfoWU$pB9Z4p@VC-dy4uPC_6=~Y6EUn69 zL(Du-%3oS{Ntt+2In)bL=+NV=xn7$xANQ{*_Yd#%d31rA2e{sHe5gDaqNp5oZqk#w z7c=Fs$N2IqsaJ*}aL$ph=xaGggU@oropi^M4o(ki7S4sH-M;bU+zYG(5I?*bgrdyi zJ^`w*PMv=s_<&SSuo>Wgw^1!(c}h$|snz1}v@Fx6yp(h&KNFnyj`HhQZI@um5{?tw zV~|_K=3yjF0>De9?u_M2%_}Wlly^LeZc_3=0$ve_6*92<*3yNYgUmVnV7Ge~l3c{K z95mBJQ{})asg^@JXq_=6a!Ff3OlSR{MRI#Ruh$F)HBqdIhhicc%~FW~OkHhM`!3He zzq3LSX9xYVn^>U5(^RAtgz3WdwfVw&w$MhU&BS(xkn0OdY6l|&mkC$_I2LMy?Wm=$(Te7J6}rw_$ckoVT311*gHf-P#XNagF-(XNxZH&CVM`GX8Ku3cjOVVr3cJ%| zKVR9QB~rs$3t2*9iE>*$pd^quUs{CA>k)Yb<67^FqFc50+NNApz5J&u1W!$1Bo>8& zbYB>RS9s?i&bc|m3gdAXrvSqnTIF1c8 zOPZDRR?lOwY%&{~0LzNNkS!ZI3r^1mxKu*zov)~5?5qDN-Ghck+7ED}^Vks#`muZf zGMCA$G%Lns05#r*tmTg9)W~||hNN>hTAAF8Qq;@cikT7;!1M;wizg!(Jf1p|?!lO? z=xmt=!lv@+wj({5hQmNLVgZg5Hz*?xY{uNP*B4e*!kAuy(Cq$%BDk!MaULNvTf-qi zj#R1{9J?XmV>X*BYC|Ks3?iWd=tJ4houtk?rDhu?9)gG2?z|P|T$;X5fcYod_l4g` zrgYNIWuDDCF-JqsFQ6Wa@>O*t^FXhhScXfRd?IAS?{1wF#WJ$x_z_{5%>g64)KXcM zPB&p}e4b^fXXW{LU4wO=-R|#UwMJY|O>|{aOnkQhqr_b+TUknB_+!}f{Rs)v+(X&Y zF*|}wJSpupyGxg}(Mhuye3`~?++pf>g?1boZdlKN!Qt7~!x^Lmg$ixo)6R}hx<%jo zKr8HWatFiS-c*Dq13};n@RI`kYl0i=&oxiF9|5b@^ZFwD3#OFXY_YtRRa_xevRp%2 zVFKa9MYR||JZ`kv>pU{XVace?oU`D3Fuk(9`jR{DY=ikIwUSz=gWHze{XI@yWLvAY|HP(g7E{J3UY9x@w_QrpvN@DGdznNq>UG1!j7O>z*$-G(S+ zIzHliw(`Y}_WWC&N?BW_rFAEpF9Vix9)h8n2nnY4p%~A<>yFNU6j5Ma2)g~#hdj#Z ze^h&V|7-W2d?3B|V&cU2+lnWsKKHl$I^|X7o5#5ih z>1!U-FR%F{?Be?G0ymZ9`u?o^>fz|ffpnq1AZOnVpT93`@E;d87hITr#=@19HQ>Vj za-7e<`HsVwp7eF;{!lbs( z+%q+Ub8Lg*X8f7oQ*}tUrhQW0z_;7B&lf4#*VOixAHkYu8@g?|cQ35H^<~R`WA%)% zi{i}XZx_Y|rvJU1k$)^l1(xH!Zrnx6?*HvR-w4e4?y`@wrtjS^mlp2HjrZL5V9l3P zMrTjy_>efamFQG2ZTBbinD=yRtTc*}nU>va96EcF4)&iG9H>wlM}E`OD!UTc-YPL$&v z&0fUNQZ#QDK5Fm!sr+HV-tP{o((c8WJT@lO_a+5m$pPS&S3 z+iFUSntEFC!^c`rj7hIzkj?!e&QNOxhECo`kH; zmNu}F8!YBP%q=Yy?uBckD&3{x-TsHL3a%<_-LDHmpVAHv&00$&(UR!(ata*awcW1m z1#@&%BAn>On|V{>*1cBMu6XF!KP0EIS&79CxKU=RPeF6*VY!^ZLTr{$vdF7VCRZ0lhkEz;zBcBP zJ53_9#wAEr$F-{zkunj7cf{qOEcbMDXfYegr7jA%(eIvt(mRk5^%_jXjhEksbdH&< zKJ|*;V5SLtpY*82EU&ed6*7?5HDTeD6{QMS-53OcuZfhKWd(jew8C z6<0D0NMe(mm+6>%wK+qD%Sz=64P+WMGlEEyLdGK)nOH0u51~O9E%{yfBFrNcNkGph zFy@LlO{RG`mJCu%*zy2&rcft92(&jZDufeeD+!2}mmwUynD_R>o1LAfcuKqCNNw59Q zl6f*~2a#%_L-%1WdooII-4TQCDJ}K12Q(rGg%Ry zydHS+Y(C0sxumYwCJ=;&Tf;Z$k@e)%w zM_tz(YNHM4!{x9YlYxLA9xs7GVS(12%_O~cd?GBh@!pul8PfbEa&0*m56uUhCNkPW z*6|EH7#`%yP(xlT2lmxMG#DUdi_M z_VreyW*3n}#^vP66Ff+>OsG=OZSo=yAsu9z*i6dKvCMW^nG7wcGdGi@#tmUGFf@kE zPC#(bjLwurb4(`;Vn$bFga;osIPy6!)NqWKWQnoYrUoh4xCmtn%Xnr$`-1x{cFfyA zL23AW)jqD<4UXCgOlv3r$(t;O(+FA`I~!+#SzrNZU(mq|o7RKA0(Ugi$U`y)2xd1| z(~ETKyAYWj6rG9g-P-KDaw z>2^8l!$$gK>G=xsxEhY;I!m3)wNIzx!6o~`adhsTtK<11HC)!PJceUoLae66l&|1gCj7s*#tki(o2G%DK%R|m0?B$o(O`R6B1`Z;U`1>h`L5{J(?d) zhy6zD<9JA}A&5njPHMQ9w|tXPuC15Xa@e74OWgug650xQa{s|OZQFotk*RPHfqUY(`o+>CRyD>C3ip2WyDhcm)E ztw>V2i&Cc?9Xj*ni6XC&d@ruh?*>qGRln8?&RsKKtzPAIt7P82SCptajRX${kGf^W2dA z>;pF{me}fi4&A6(LJ>SK&FmOi+WBV9y7Z`R5o=%j$v*n=qi1gm+j{Va7uW-(y@P$q ztj!T81k>Wz*Hp72&s_KTx4sBDf-Cv=FE#l6_0#_M6x%;9O0egbpRaC?D!BJbH2&1Q zLt8Bge81V+Qt8Z`?U6r>U71?;gP(KP&Th-1bBL^O;a;Da57#O-d#b;!`KTcL!k&pU z)d?PsONRFE4aj-sw@Npgb1F;M#|$+1f9t+ytZLM83$e*D-*e)^+hd$Q?ns>f)tGnP zI}3{{r$h|8O<3o@%kM@;;ZJufy#D?X!2ek7Jh0k)BQ=Nm9tF#taq%66|LKeu%HhY) z=YKK3y_ELGJTSL*?zRf6h_~mV{Lcwd!(UX7ym~zLRnf0+Pk#G=Pb<&v9#gQP=eKj? zbZ1BCbJgFQXLnDrElwJ`e`*6XeskWoWApEi(_Gky%nvzww7e{~{yWzbzdm!U4y1qA z*ExpS^G9QU-?Bq1KdHa=UEAQwi*r89cKf(Heslg00gHZ5{$qA&I^6zk-;UDc(|^vN z6PKU(W9_R~BZ<@#v&VrHqBRqMIc}!%%cLl>;nykCHtk+OcK6O+X4*IB?WYCKizB9# zO)85C?+Na?xUGVyIU4eN2F#IcxwCB9*MY8=_6WC4n)Ojg@OO__Jl-&M=BkCQN6Y3W zJo>Wx+R3f9{m#_NSs5AYWP|H3+ztHUe(1inFaExH(9nO%+;QOMF;%Zm|Ca%)@2@J? zdrkcG;gE`;iP#q3qnyjE8??)hT5mq-;O*}iduGhCZw6^Wuvbo<|1@jZSdUgpPQslV=@0>Ehc;x4i>1))rA56s4P&0gm+(uK{Z3ma(4}!@?$=TiQ>D9GH|lMCltalH_?lI|1XR@}N=7-;GvB$42wv zJ{d`V7~s=P=S5kx(^Z{S;RGtznzM^c=4_kHi4e^72V`0QMd<v@jR){i8LZtF{r>^RN#E^;d zD;uj%&SC@mHI6uWhsXPJ6U!O`6o{c=PE>{i?at)L$Tw7?&O;Z#iU!C}+ zeQr`>Oa>C`>Y%JhYpA$*xYY?_`k8RH(-#d5u~0;n%^L_R-V(rW5Hm0aLYu54U6ss% zIJs99*Z5FK@|)u2`f!3Fl9FK1&{+?-fgEy#2(lrp>PXwAlN+gpldH^_D>|8A!gU;Z z8;{Qc@o2&*dP;7MF)xv8v)2kL3lZ$Grb%fJC+NAM`p!a>$GguHmLnp__a0wR!@W@D z-P6NZFRQZZ9qFlsk|;BuXRr$Gq@xj66SfT7HrE<%+l9&ZNM$+SR!ZfR8I|05R2tw? zpk<`KKwKEUWQ5@G>kgI$rVIKHCwUwShn1T<_>aN~%cES^03Z$yCL=r#YChcRC(vsmaA`M73=rKI zS4-70y3tzNK<^r_)$#4?mVv#^%aLlNM;#)sP zs;9Z$lEb`)e^Ky+{Y_AIdR<{O1Ep;h?oCJB)NUoJ) zy_On$eYI3SX6bB?BClGbSN#rXxYz5Ux1VEAICPW{Sp8*{Om|B-m|(ae^RG)2jml3sq%Ti0p zpAz!T&rj8=3X~ybNYpESSv6=!qC8(t%3+__q9JQW9+E=qhPmJqiG|}n1VqY} zRkpE=pyrFB$cn{&E_9Q#h-pAYLV#eflrk7}u3XvK8%5CL^R(trZa>B|%vCW*iT=G5 zzCm9~N_cG?o2&q$_vhpK&H#6ReYX{#=c$vMgM?(UrOki9Q0D-?OLC3UIx1n2a8S|& zmx82R1S?co2C<+aTC3(JNsJ&crhk7&)7<4~RGumxAsfT#DwhO@t5~ZhdbE{Y^ z{r)NN%-KKWIFvQ>5o-V{1}Va;TDbB+-R#^6D93o zSWNZxHlZ1$>mK1n^K3z8-FExmn>_a)6;dMDVlaP;XE z-6MPH&QjguS;dccq&3tBXznbS?z?3${ZEha=?9~mY;O)m1^;y zeDL2dm;U4B$>8PuKM!sFd$a*me}6$sHm-T_>)0Z zF5R$U!|RsQ>wXjzU806@r(}KRnfLO+1?dk{lJY6Soc@i5`pdJ&hFzNPxiXi`om}tc zV4TyM>ixZacihpWpI)qdHRtIcZ{9t&7{af=|Eld@moM(U|#8$2nBlkGipK+S?GHmKuRV`tnK*_TqS9K!SAZG7L{fN_tr)M-BsA@W?^A24q>WXhX zw90jx5>v zeSxh3@X;zp7iev$Vk2dkiNL|wrJ5b=GUzBiSZ-s&yem_!>&8T)sz2F809(YPH^RV- z&a>lx@v1#R-bc5R3msWIE+w~mnf52OlC}F zj}8P-ed1VUJw+TTI0tlRkDJSUM*z#922?2AOj#Wp0phHXip1kvUNnA1BO?Qu4`o*y z^%@i2MjI_+1FE|mAFR_0P|Oa7eVwtvo?RBsxtCY-rDuow)bY;L6a>6eww0Eg@7Mzn z{XL$s@P0;M&!J86FIFSFvVCgHJV;LixZ92b_b|Fj z+y2548rNO%%XKX!tcY`kfjFhe^Eh0sfKJJdDj$xB81_!&35!H7{QL6zyp~nd?@xv- zPX1B~g+p&UB``j;)sgn6r|LiELA4WmQTe@49uIte)of>~6Nd5Z_WMs)L9NuO-<#&lPt}sp*nhfiwy+|+`?ZOqA z(IjpYbK;y3N8md^Xr3@Le$)Nq?*&ukTDpi+3O%&1~(`g19<&^OF`8t@eZJ|)^Mts^023S!x)P(NhwLc1#;+9YD+wdf}wkFl<^Siq^!`yKmVqs!*be+6`nEpU1@@66r;?|qDc-ku_}%H$_RnYalk=0(v^6j|%l`CZ}T z#>hKiw@ZM>2FHRVQ7ke+#p~l1p}~JOs}=Yptd_LfOYHUe3D&a)M^}o~ih1K|7$E>1 zi2&vTN1)PkNE^pw(?$pFg4yv8rVlDGQ~ALr;7F0@W#J;A^@0Hc1;XITZB|6hkmNk7 zGzz0z)PD4UOuNv=5O#L(MuAh~2wUR`?jf6}Lot>y$`DTD)-G?h>vUEWRz&1mX_NZ~ zf!6Pmz(zpF2#etGtQ^v^58`@2gJsv6+~q~-%cV}ufA99Hf7B`S!EWDHH){Q_-QEeJ zu2~SY5hg>%Upk!-v#ZCJe+pUQ^lazJ?e!buw@uepXJ&n(Zr404sO`(OH8U9(dD{4JATz2BGCx40&IH2UIav^ z3{?lxF~8#6bhsbj%2x!Ql?{7;YO5N2u{byV)>7DZ{@(GqvW=$w$|e0%PP}dK{%Yw& zCH=?FKkt2NH5Xpq_Oga;?tV0pY0BHfp4>f>`@p#*_Wh3+s@Dc~zL=li{PH`6*3yvLs^>%e&ViVTBvs5!!Db?LLd#=MH3)WB_qZ8-6_-#ytw7>=S z`8xK4r4}7YXUxdf&QCA_v%EuMbmm>hpi`=^|8M>BGt)}GRE=?&dE>y=q+QB`VIA`$ zYiE90`?>$jD@xzrX0CBLK3IBp?dqUNnQh&C7EuLg<@ya1R(bjHxA&Zhd?`Kue`%Lr zs3zwM@Bie4l-Q4{Vu<0y7b$P-1EmKK3nOO<##GKaOIB3*`uLj+V{0P zeDm?Q`UNXLQ7I`rNh$ZugGpms z{g&R(F4(fe`Il{Le}g`Ma3FL}#C7`ojpiTyyyDMSK7EK?UgsEAGn{%jx<)z1hFy{c zIwn@hOG6e+S7QWO{;5x-o$yqxJLEKK^?%eiA@KjhPG8D7?AtbV@o)Wu3A*{-ebKBn zi=IYCj|t8`cX{)Piyb{X^9MqcW4bqsY(cHXj-URAU_NI2x4)cUU2=YPdVI#(mzl#) z4mD(1@1T#`e%(K{?Bx&FcwBSYf{rTpZ&z+Oz3Fa97x%+Cr&Bk++Bg1aaM&RcZN4|h z5RiO-1uHk;s|8IYiuLnq4O#8Sf`@VVJeuM8FMN6Xv4sA~HfQnP7S5bygWu1aT$Irk zx=wCK6Fgl_i;tL=WPGkZwadw*>!19~Dj5FI>G-m@%yaME|A?v@_x_!7?#qHI>+CCE zb~Q8RQxc{I_w@91`y(yOtJWiR$w%0A2m7yRqQ_nS@nN}nLUP!`1NpmtZgHEInVp#_ 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 literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/dummy_video.webm b/test/wpt/tests/fetch/api/request/destination/resources/dummy_video.webm new file mode 100644 index 0000000000000000000000000000000000000000..c3d433a3e02e86eee1026b6620dc0c50498e61a4 GIT binary patch literal 96902 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%0AxWB;E~=xj7<{Wt4OeKYACt+d&&88fiV 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 O{=nA(AP!glu>TjvI>Ctm literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/empty.https.html b/test/wpt/tests/fetch/api/request/destination/resources/empty.https.html new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-frame.js b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-frame.js new file mode 100644 index 0000000..b69de0b --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-frame.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + event.waitUntil(async function() { + let destination = new URL(event.request.url).searchParams.get("dest"); + let clients = await self.clients.matchAll({"includeUncontrolled": true}); + clients.forEach(function(client) { + if (client.url.includes("fetch-destination-frame")) { + if (event.request.destination == destination) { + client.postMessage("PASS"); + } else { + client.postMessage("FAIL"); + } + } + }) + }()); + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js new file mode 100644 index 0000000..7634583 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + event.waitUntil(async function() { + let destination = new URL(event.request.url).searchParams.get("dest"); + let clients = await self.clients.matchAll({"includeUncontrolled": true}); + clients.forEach(function(client) { + if (client.url.includes("fetch-destination-iframe")) { + if (event.request.destination == destination) { + client.postMessage("PASS"); + } else { + client.postMessage("FAIL"); + } + } + }) + }()); + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js new file mode 100644 index 0000000..a583b12 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + const url = event.request.url; + if (url.includes('dummy') && url.includes('?')) { + event.waitUntil(async function() { + let destination = new URL(url).searchParams.get("dest"); + var result = "FAIL"; + if (event.request.destination == destination || + (event.request.destination == "empty" && destination == "")) { + result = "PASS"; + } + let cl = await clients.matchAll({includeUncontrolled: true}); + for (i = 0; i < cl.length; i++) { + cl[i].postMessage(result); + } + }()) + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker.js b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker.js new file mode 100644 index 0000000..904009c --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/fetch-destination-worker.js @@ -0,0 +1,12 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + let destination = new URL(event.request.url).searchParams.get("dest"); + if (event.request.destination == destination || + (event.request.destination == "empty" && destination == "")) { + event.respondWith(fetch(event.request)); + } else { + event.respondWith(Response.error()); + } + } +}); + diff --git a/test/wpt/tests/fetch/api/request/destination/resources/importer.js b/test/wpt/tests/fetch/api/request/destination/resources/importer.js new file mode 100644 index 0000000..9568474 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/destination/resources/importer.js @@ -0,0 +1 @@ +importScripts("dummy?t=importScripts&dest=script"); diff --git a/test/wpt/tests/fetch/api/request/forbidden-method.any.js b/test/wpt/tests/fetch/api/request/forbidden-method.any.js new file mode 100644 index 0000000..eb13f37 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/forbidden-method.any.js @@ -0,0 +1,13 @@ +// META: global=window,worker + +// https://fetch.spec.whatwg.org/#forbidden-method +for (const method of [ + 'CONNECT', 'TRACE', 'TRACK', + 'connect', 'trace', 'track' + ]) { + test(function() { + assert_throws_js(TypeError, + function() { new Request('./', {method: method}); } + ); + }, 'Request() with a forbidden method ' + method + ' must throw.'); +} diff --git a/test/wpt/tests/fetch/api/request/multi-globals/construct-in-detached-frame.window.js b/test/wpt/tests/fetch/api/request/multi-globals/construct-in-detached-frame.window.js new file mode 100644 index 0000000..b0d6ba5 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/multi-globals/construct-in-detached-frame.window.js @@ -0,0 +1,11 @@ +// This is a regression test for Chromium issue https://crbug.com/1427266. +test(() => { + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const otherRequest = iframe.contentWindow.Request; + iframe.remove(); + const r1 = new otherRequest('resource', { method: 'POST', body: 'string' }); + const r2 = new otherRequest(r1); + assert_true(r1.bodyUsed); + assert_false(r2.bodyUsed); +}, 'creating a request from another request in a detached realm should work'); diff --git a/test/wpt/tests/fetch/api/request/multi-globals/current/current.html b/test/wpt/tests/fetch/api/request/multi-globals/current/current.html new file mode 100644 index 0000000..9bb6e0b --- /dev/null +++ b/test/wpt/tests/fetch/api/request/multi-globals/current/current.html @@ -0,0 +1,3 @@ + +Current page used as a test helper + diff --git a/test/wpt/tests/fetch/api/request/multi-globals/incumbent/incumbent.html b/test/wpt/tests/fetch/api/request/multi-globals/incumbent/incumbent.html new file mode 100644 index 0000000..a885b8a --- /dev/null +++ b/test/wpt/tests/fetch/api/request/multi-globals/incumbent/incumbent.html @@ -0,0 +1,14 @@ + +Incumbent page used as a test helper + + + + diff --git a/test/wpt/tests/fetch/api/request/multi-globals/url-parsing.html b/test/wpt/tests/fetch/api/request/multi-globals/url-parsing.html new file mode 100644 index 0000000..df60e72 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/multi-globals/url-parsing.html @@ -0,0 +1,27 @@ + +Request constructor URL parsing, with multiple globals in play + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-bad-port.any.js b/test/wpt/tests/fetch/api/request/request-bad-port.any.js new file mode 100644 index 0000000..b0684d4 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-bad-port.any.js @@ -0,0 +1,92 @@ +// META: global=window,worker + +// list of bad ports according to +// https://fetch.spec.whatwg.org/#port-blocking +var BLOCKED_PORTS_LIST = [ + 1, // tcpmux + 7, // echo + 9, // discard + 11, // systat + 13, // daytime + 15, // netstat + 17, // qotd + 19, // chargen + 20, // ftp-data + 21, // ftp + 22, // ssh + 23, // telnet + 25, // smtp + 37, // time + 42, // name + 43, // nicname + 53, // domain + 69, // tftp + 77, // priv-rjs + 79, // finger + 87, // ttylink + 95, // supdup + 101, // hostriame + 102, // iso-tsap + 103, // gppitnp + 104, // acr-nema + 109, // pop2 + 110, // pop3 + 111, // sunrpc + 113, // auth + 115, // sftp + 117, // uucp-path + 119, // nntp + 123, // ntp + 135, // loc-srv / epmap + 137, // netbios-ns + 139, // netbios-ssn + 143, // imap2 + 161, // snmp + 179, // bgp + 389, // ldap + 427, // afp (alternate) + 465, // smtp (alternate) + 512, // print / exec + 513, // login + 514, // shell + 515, // printer + 526, // tempo + 530, // courier + 531, // chat + 532, // netnews + 540, // uucp + 548, // afp + 554, // rtsp + 556, // remotefs + 563, // nntp+ssl + 587, // smtp (outgoing) + 601, // syslog-conn + 636, // ldap+ssl + 989, // ftps-data + 990, // ftps + 993, // ldap+ssl + 995, // pop3+ssl + 1719, // h323gatestat + 1720, // h323hostcall + 1723, // pptp + 2049, // nfs + 3659, // apple-sasl + 4045, // lockd + 5060, // sip + 5061, // sips + 6000, // x11 + 6566, // sane-port + 6665, // irc (alternate) + 6666, // irc (alternate) + 6667, // irc (default) + 6668, // irc (alternate) + 6669, // irc (alternate) + 6697, // irc+tls + 10080, // amanda +]; + +BLOCKED_PORTS_LIST.map(function(a){ + promise_test(function(t){ + return promise_rejects_js(t, TypeError, fetch("http://example.com:" + a)) + }, 'Request on bad port ' + a + ' should throw TypeError.'); +}); diff --git a/test/wpt/tests/fetch/api/request/request-cache-default-conditional.any.js b/test/wpt/tests/fetch/api/request/request-cache-default-conditional.any.js new file mode 100644 index 0000000..c5b2001 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-default-conditional.any.js @@ -0,0 +1,170 @@ +// META: global=window,worker +// META: title=Request cache - default with conditional requests +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "default" mode with an If-Modified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Modified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Modified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Modified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Modified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-None-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-None-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-None-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-None-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Unmodified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Unmodified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Unmodified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Unmodified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Match header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Match header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Range header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Range": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Range header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Range": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Range header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Range": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Range header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Range": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-default.any.js b/test/wpt/tests/fetch/api/request/request-cache-default.any.js new file mode 100644 index 0000000..dfa8369 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-default.any.js @@ -0,0 +1,39 @@ +// META: global=window,worker +// META: title=Request cache - default +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "default" mode checks the cache for previously cached content and goes to the network for stale responses', + state: "stale", + request_cache: ["default", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "default" mode checks the cache for previously cached content and avoids going to the network if a fresh response exists', + state: "fresh", + request_cache: ["default", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'Responses with the "Cache-Control: no-store" header are not stored in the cache', + state: "stale", + cache_control: "no-store", + request_cache: ["default", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'Responses with the "Cache-Control: no-store" header are not stored in the cache', + state: "fresh", + cache_control: "no-store", + request_cache: ["default", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-force-cache.any.js b/test/wpt/tests/fetch/api/request/request-cache-force-cache.any.js new file mode 100644 index 0000000..00dce09 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-force-cache.any.js @@ -0,0 +1,67 @@ +// META: global=window,worker +// META: title=Request cache - force-cache +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and avoid revalidation for stale responses', + state: "stale", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and avoid revalidation for fresh responses', + state: "fresh", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response is not found', + state: "stale", + request_cache: ["force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response is not found', + state: "fresh", + request_cache: ["force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response would vary', + state: "stale", + vary: "*", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response would vary', + state: "fresh", + vary: "*", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" stores the response in the cache if it goes to the network', + state: "stale", + request_cache: ["force-cache", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" stores the response in the cache if it goes to the network', + state: "fresh", + request_cache: ["force-cache", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-no-cache.any.js b/test/wpt/tests/fetch/api/request/request-cache-no-cache.any.js new file mode 100644 index 0000000..41fc22b --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-no-cache.any.js @@ -0,0 +1,25 @@ +// META: global=window,worker +// META: title=Request cache : no-cache +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "no-cache" mode revalidates stale responses found in the cache', + state: "stale", + request_cache: ["default", "no-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + expected_max_age_headers: [false, true], + }, + { + name: 'RequestCache "no-cache" mode revalidates fresh responses found in the cache', + state: "fresh", + request_cache: ["default", "no-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + expected_max_age_headers: [false, true], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-no-store.any.js b/test/wpt/tests/fetch/api/request/request-cache-no-store.any.js new file mode 100644 index 0000000..9a28718 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-no-store.any.js @@ -0,0 +1,37 @@ +// META: global=window,worker +// META: title=Request cache - no store +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "no-store" mode does not check the cache for previously cached content and goes to the network regardless', + state: "stale", + request_cache: ["default", "no-store"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "no-store" mode does not check the cache for previously cached content and goes to the network regardless', + state: "fresh", + request_cache: ["default", "no-store"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "no-store" mode does not store the response in the cache', + state: "stale", + request_cache: ["no-store", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "no-store" mode does not store the response in the cache', + state: "fresh", + request_cache: ["no-store", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-only-if-cached.any.js b/test/wpt/tests/fetch/api/request/request-cache-only-if-cached.any.js new file mode 100644 index 0000000..1305787 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-only-if-cached.any.js @@ -0,0 +1,66 @@ +// META: global=window,dedicatedworker,sharedworker +// META: title=Request cache - only-if-cached +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +// FIXME: avoid mixed content requests to enable service worker global +var tests = [ + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and avoids revalidation for stale responses', + state: "stale", + request_cache: ["default", "only-if-cached"], + expected_validation_headers: [false], + expected_no_cache_headers: [false] + }, + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and avoids revalidation for fresh responses', + state: "fresh", + request_cache: ["default", "only-if-cached"], + expected_validation_headers: [false], + expected_no_cache_headers: [false] + }, + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and does not go to the network if a cached response is not found', + state: "fresh", + request_cache: ["only-if-cached"], + response: ["error"], + expected_validation_headers: [], + expected_no_cache_headers: [] + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") uses cached same-origin redirects to same-origin content', + state: "fresh", + request_cache: ["default", "only-if-cached"], + redirect: "same-origin", + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") uses cached same-origin redirects to same-origin content', + state: "stale", + request_cache: ["default", "only-if-cached"], + redirect: "same-origin", + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") does not follow redirects across origins and rejects', + state: "fresh", + request_cache: ["default", "only-if-cached"], + redirect: "cross-origin", + response: [null, "error"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") does not follow redirects across origins and rejects', + state: "stale", + request_cache: ["default", "only-if-cached"], + redirect: "cross-origin", + response: [null, "error"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache-reload.any.js b/test/wpt/tests/fetch/api/request/request-cache-reload.any.js new file mode 100644 index 0000000..c7bfffb --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache-reload.any.js @@ -0,0 +1,51 @@ +// META: global=window,worker +// META: title=Request cache - reload +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "reload" mode does not check the cache for previously cached content and goes to the network regardless', + state: "stale", + request_cache: ["default", "reload"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "reload" mode does not check the cache for previously cached content and goes to the network regardless', + state: "fresh", + request_cache: ["default", "reload"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache', + state: "stale", + request_cache: ["reload", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache', + state: "fresh", + request_cache: ["reload", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [true], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache even if a previous response is already stored', + state: "stale", + request_cache: ["default", "reload", "default"], + expected_validation_headers: [false, false, true], + expected_no_cache_headers: [false, true, false], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache even if a previous response is already stored', + state: "fresh", + request_cache: ["default", "reload", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/api/request/request-cache.js b/test/wpt/tests/fetch/api/request/request-cache.js new file mode 100644 index 0000000..f2fbecf --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-cache.js @@ -0,0 +1,223 @@ +/** + * Each test is run twice: once using etag/If-None-Match and once with + * date/If-Modified-Since. Each test run gets its own URL and randomized + * content and operates independently. + * + * The test steps are run with request_cache.length fetch requests issued + * and their immediate results sanity-checked. The cache.py server script + * stashes an entry containing any If-None-Match, If-Modified-Since, Pragma, + * and Cache-Control observed headers for each request it receives. When + * the test fetches have run, this state is retrieved from cache.py and the + * expected_* lists are checked, including their length. + * + * This means that if a request_* fetch is expected to hit the cache and not + * touch the network, then there will be no entry for it in the expect_* + * lists. AKA (request_cache.length - expected_validation_headers.length) + * should equal the number of cache hits that didn't touch the network. + * + * Test dictionary keys: + * - state: required string that determines whether the Expires response for + * the fetched document should be set in the future ("fresh") or past + * ("stale"). + * - vary: optional string to be passed to the server for it to quote back + * in a Vary header on the response to us. + * - cache_control: optional string to be passed to the server for it to + * quote back in a Cache-Control header on the response to us. + * - redirect: optional string "same-origin" or "cross-origin". If + * provided, the server will issue an absolute redirect to the script on + * the same or a different origin, as appropriate. The redirected + * location is the script with the redirect parameter removed, so the + * content/state/etc. will be as if you hadn't specified a redirect. + * - request_cache: required array of cache modes to use (via `cache`). + * - request_headers: optional array of explicit fetch `headers` arguments. + * If provided, the server will log an empty dictionary for each request + * instead of the request headers it would normally log. + * - response: optional array of specialized response handling. Right now, + * "error" array entries indicate a network error response is expected + * which will reject with a TypeError. + * - expected_validation_headers: required boolean array indicating whether + * the server should have seen an If-None-Match/If-Modified-Since header + * in the request. + * - expected_no_cache_headers: required boolean array indicating whether + * the server should have seen Pragma/Cache-control:no-cache headers in + * the request. + * - expected_max_age_headers: optional boolean array indicating whether + * the server should have seen a Cache-Control:max-age=0 header in the + * request. + */ + +var now = new Date(); + +function base_path() { + return location.pathname.replace(/\/[^\/]*$/, '/'); +} +function make_url(uuid, id, value, content, info) { + var dates = { + fresh: new Date(now.getFullYear() + 1, now.getMonth(), now.getDay()).toGMTString(), + stale: new Date(now.getFullYear() - 1, now.getMonth(), now.getDay()).toGMTString(), + }; + var vary = ""; + if ("vary" in info) { + vary = "&vary=" + info.vary; + } + var cache_control = ""; + if ("cache_control" in info) { + cache_control = "&cache_control=" + info.cache_control; + } + var redirect = ""; + + var ignore_request_headers = ""; + if ("request_headers" in info) { + // Ignore the request headers that we send since they may be synthesized by the test. + ignore_request_headers = "&ignore"; + } + var url_sans_redirect = "resources/cache.py?token=" + uuid + + "&content=" + content + + "&" + id + "=" + value + + "&expires=" + dates[info.state] + + vary + cache_control + ignore_request_headers; + // If there's a redirect, the target is the script without any redirect at + // either the same domain or a different domain. + if ("redirect" in info) { + var host_info = get_host_info(); + var origin; + switch (info.redirect) { + case "same-origin": + origin = host_info['HTTP_ORIGIN']; + break; + case "cross-origin": + origin = host_info['HTTP_REMOTE_ORIGIN']; + break; + } + var redirected_url = origin + base_path() + url_sans_redirect; + return url_sans_redirect + "&redirect=" + encodeURIComponent(redirected_url); + } else { + return url_sans_redirect; + } +} +function expected_status(type, identifier, init) { + if (type == "date" && + init.headers && + init.headers["If-Modified-Since"] == identifier) { + // The server will respond with a 304 in this case. + return [304, "Not Modified"]; + } + return [200, "OK"]; +} +function expected_response_text(type, identifier, init, content) { + if (type == "date" && + init.headers && + init.headers["If-Modified-Since"] == identifier) { + // The server will respond with a 304 in this case. + return ""; + } + return content; +} +function server_state(uuid) { + return fetch("resources/cache.py?querystate&token=" + uuid) + .then(function(response) { + return response.text(); + }).then(function(text) { + // null will be returned if the server never received any requests + // for the given uuid. Normalize that to an empty list consistent + // with our representation. + return JSON.parse(text) || []; + }); +} +function make_test(type, info) { + return function(test) { + var uuid = token(); + var identifier = (type == "tag" ? Math.random() : now.toGMTString()); + var content = Math.random().toString(); + var url = make_url(uuid, type, identifier, content, info); + var fetch_functions = []; + for (var i = 0; i < info.request_cache.length; ++i) { + fetch_functions.push(function(idx) { + var init = {cache: info.request_cache[idx]}; + if ("request_headers" in info) { + init.headers = info.request_headers[idx]; + } + if (init.cache === "only-if-cached") { + // only-if-cached requires we use same-origin mode. + init.mode = "same-origin"; + } + return fetch(url, init) + .then(function(response) { + if ("response" in info && info.response[idx] === "error") { + assert_true(false, "fetch should have been an error"); + return; + } + assert_array_equals([response.status, response.statusText], + expected_status(type, identifier, init)); + return response.text(); + }).then(function(text) { + assert_equals(text, expected_response_text(type, identifier, init, content)); + }, function(reason) { + if ("response" in info && info.response[idx] === "error") { + assert_throws_js(TypeError, function() { throw reason; }); + } else { + throw reason; + } + }); + }); + } + var i = 0; + function run_next_step() { + if (fetch_functions.length) { + return fetch_functions.shift()(i++) + .then(run_next_step); + } else { + return Promise.resolve(); + } + } + return run_next_step() + .then(function() { + // Now, query the server state + return server_state(uuid); + }).then(function(state) { + var expectedState = []; + info.expected_validation_headers.forEach(function (validate) { + if (validate) { + if (type == "tag") { + expectedState.push({"If-None-Match": '"' + identifier + '"'}); + } else { + expectedState.push({"If-Modified-Since": identifier}); + } + } else { + expectedState.push({}); + } + }); + for (var i = 0; i < info.expected_no_cache_headers.length; ++i) { + if (info.expected_no_cache_headers[i]) { + expectedState[i]["Pragma"] = "no-cache"; + expectedState[i]["Cache-Control"] = "no-cache"; + } + } + if ("expected_max_age_headers" in info) { + for (var i = 0; i < info.expected_max_age_headers.length; ++i) { + if (info.expected_max_age_headers[i]) { + expectedState[i]["Cache-Control"] = "max-age=0"; + } + } + } + assert_equals(state.length, expectedState.length); + for (var i = 0; i < state.length; ++i) { + for (var header in state[i]) { + assert_equals(state[i][header], expectedState[i][header]); + delete expectedState[i][header]; + } + for (var header in expectedState[i]) { + assert_false(header in state[i]); + } + } + }); + }; +} + +function run_tests(tests) +{ + tests.forEach(function(info) { + promise_test(make_test("tag", info), info.name + " with Etag and " + info.state + " response"); + promise_test(make_test("date", info), info.name + " with Last-Modified and " + info.state + " response"); + }); +} diff --git a/test/wpt/tests/fetch/api/request/request-clone.sub.html b/test/wpt/tests/fetch/api/request/request-clone.sub.html new file mode 100644 index 0000000..c690bb3 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-clone.sub.html @@ -0,0 +1,63 @@ + + + + + Request clone + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-consume-empty.any.js b/test/wpt/tests/fetch/api/request/request-consume-empty.any.js new file mode 100644 index 0000000..034a860 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-consume-empty.any.js @@ -0,0 +1,101 @@ +// META: global=window,worker +// META: title=Request consume empty bodies + +function checkBodyText(test, request) { + return request.text().then(function(bodyAsText) { + assert_equals(bodyAsText, "", "Resolved value should be empty"); + assert_false(request.bodyUsed); + }); +} + +function checkBodyBlob(test, request) { + return request.blob().then(function(bodyAsBlob) { + var promise = new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function(evt) { + resolve(reader.result) + }; + reader.onerror = function() { + reject("Blob's reader failed"); + }; + reader.readAsText(bodyAsBlob); + }); + return promise.then(function(body) { + assert_equals(body, "", "Resolved value should be empty"); + assert_false(request.bodyUsed); + }); + }); +} + +function checkBodyArrayBuffer(test, request) { + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_false(request.bodyUsed); + }); +} + +function checkBodyJSON(test, request) { + return request.json().then( + function(bodyAsJSON) { + assert_unreached("JSON parsing should fail"); + }, + function() { + assert_false(request.bodyUsed); + }); +} + +function checkBodyFormData(test, request) { + return request.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_false(request.bodyUsed); + }); +} + +function checkBodyFormDataError(test, request) { + return promise_rejects_js(test, TypeError, request.formData()).then(function() { + assert_false(request.bodyUsed); + }); +} + +function checkRequestWithNoBody(bodyType, checkFunction, headers = []) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "headers": headers}); + assert_false(request.bodyUsed); + return checkFunction(test, request); + }, "Consume request's body as " + bodyType); +} + +checkRequestWithNoBody("text", checkBodyText); +checkRequestWithNoBody("blob", checkBodyBlob); +checkRequestWithNoBody("arrayBuffer", checkBodyArrayBuffer); +checkRequestWithNoBody("json (error case)", checkBodyJSON); +checkRequestWithNoBody("formData with correct multipart type (error case)", checkBodyFormDataError, [["Content-Type", 'multipart/form-data; boundary="boundary"']]); +checkRequestWithNoBody("formData with correct urlencoded type", checkBodyFormData, [["Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"]]); +checkRequestWithNoBody("formData without correct type (error case)", checkBodyFormDataError); + +function checkRequestWithEmptyBody(bodyType, body, asText) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body}); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + if (asText) { + return request.text().then(function(bodyAsString) { + assert_equals(bodyAsString.length, 0, "Resolved value should be empty"); + assert_true(request.bodyUsed, "bodyUsed is true after being consumed"); + }); + } + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_true(request.bodyUsed, "bodyUsed is true after being consumed"); + }); + }, "Consume empty " + bodyType + " request body as " + (asText ? "text" : "arrayBuffer")); +} + +// FIXME: Add BufferSource, FormData and URLSearchParams. +checkRequestWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), false); +checkRequestWithEmptyBody("text", "", false); +checkRequestWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), true); +checkRequestWithEmptyBody("text", "", true); +checkRequestWithEmptyBody("URLSearchParams", new URLSearchParams(""), true); +// FIXME: This test assumes that the empty string be returned but it is not clear whether that is right. See https://github.com/web-platform-tests/wpt/pull/3950. +checkRequestWithEmptyBody("FormData", new FormData(), true); +checkRequestWithEmptyBody("ArrayBuffer", new ArrayBuffer(), true); diff --git a/test/wpt/tests/fetch/api/request/request-consume.any.js b/test/wpt/tests/fetch/api/request/request-consume.any.js new file mode 100644 index 0000000..aff5d65 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-consume.any.js @@ -0,0 +1,145 @@ +// META: global=window,worker +// META: title=Request consume +// META: script=../resources/utils.js + +function checkBodyText(request, expectedBody) { + return request.text().then(function(bodyAsText) { + assert_equals(bodyAsText, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as text: bodyUsed turned true"); + }); +} + +function checkBodyBlob(request, expectedBody, checkContentType) { + return request.blob().then(function(bodyAsBlob) { + if (checkContentType) + assert_equals(bodyAsBlob.type, "text/plain", "Blob body type should be computed from the request Content-Type"); + + var promise = new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function(evt) { + resolve(reader.result) + }; + reader.onerror = function() { + reject("Blob's reader failed"); + }; + reader.readAsText(bodyAsBlob); + }); + return promise.then(function(body) { + assert_equals(body, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as blob: bodyUsed turned true"); + }); + }); +} + +function checkBodyArrayBuffer(request, expectedBody) { + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + validateBufferFromString(bodyAsArrayBuffer, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as arrayBuffer: bodyUsed turned true"); + }); +} + +function checkBodyJSON(request, expectedBody) { + return request.json().then(function(bodyAsJSON) { + var strBody = JSON.stringify(bodyAsJSON) + assert_equals(strBody, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as json: bodyUsed turned true"); + }); +} + +function checkBodyFormData(request, expectedBody) { + return request.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_true(request.bodyUsed, "body as formData: bodyUsed turned true"); + }); +} + +function checkRequestBody(body, expected, bodyType) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body, "headers": [["Content-Type", "text/PLAIN"]] }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyText(request, expected); + }, "Consume " + bodyType + " request's body as text"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyBlob(request, expected); + }, "Consume " + bodyType + " request's body as blob"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyArrayBuffer(request, expected); + }, "Consume " + bodyType + " request's body as arrayBuffer"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyJSON(request, expected); + }, "Consume " + bodyType + " request's body as JSON"); +} + +var textData = JSON.stringify("This is response's body"); +var blob = new Blob([textData], { "type" : "text/plain" }); + +checkRequestBody(textData, textData, "String"); + +var string = "\"123456\""; +function getArrayBuffer() { + var arrayBuffer = new ArrayBuffer(8); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < 8; cptr++) + int8Array[cptr] = string.charCodeAt(cptr); + return arrayBuffer; +} + +function getArrayBufferWithZeros() { + var arrayBuffer = new ArrayBuffer(10); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < 8; cptr++) + int8Array[cptr + 1] = string.charCodeAt(cptr); + return arrayBuffer; +} + +checkRequestBody(getArrayBuffer(), string, "ArrayBuffer"); +checkRequestBody(new Uint8Array(getArrayBuffer()), string, "Uint8Array"); +checkRequestBody(new Int8Array(getArrayBufferWithZeros(), 1, 8), string, "Int8Array"); +checkRequestBody(new Float32Array(getArrayBuffer()), string, "Float32Array"); +checkRequestBody(new DataView(getArrayBufferWithZeros(), 1, 8), string, "DataView"); + +promise_test(function(test) { + var formData = new FormData(); + formData.append("name", "value") + var request = new Request("", {"method": "POST", "body": formData }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyFormData(request, formData); +}, "Consume FormData request's body as FormData"); + +function checkBlobResponseBody(blobBody, blobData, bodyType, checkFunction) { + promise_test(function(test) { + var response = new Response(blobBody); + assert_false(response.bodyUsed, "bodyUsed is false at init"); + return checkFunction(response, blobData); + }, "Consume blob response's body as " + bodyType); +} + +checkBlobResponseBody(blob, textData, "blob", checkBodyBlob); +checkBlobResponseBody(blob, textData, "text", checkBodyText); +checkBlobResponseBody(blob, textData, "json", checkBodyJSON); +checkBlobResponseBody(blob, textData, "arrayBuffer", checkBodyArrayBuffer); +checkBlobResponseBody(new Blob([""]), "", "blob (empty blob as input)", checkBodyBlob); + +var goodJSONValues = ["null", "1", "true", "\"string\""]; +goodJSONValues.forEach(function(value) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": value}); + return request.json().then(function(v) { + assert_equals(v, JSON.parse(value)); + }); + }, "Consume JSON from text: '" + JSON.stringify(value) + "'"); +}); + +var badJSONValues = ["undefined", "{", "a", "["]; +badJSONValues.forEach(function(value) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": value}); + return promise_rejects_js(test, SyntaxError, request.json()); + }, "Trying to consume bad JSON text as JSON: '" + value + "'"); +}); diff --git a/test/wpt/tests/fetch/api/request/request-disturbed.any.js b/test/wpt/tests/fetch/api/request/request-disturbed.any.js new file mode 100644 index 0000000..8a11de7 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-disturbed.any.js @@ -0,0 +1,109 @@ +// META: global=window,worker +// META: title=Request disturbed +// META: script=../resources/utils.js + +var initValuesDict = {"method" : "POST", + "body" : "Request's body" +}; + +var noBodyConsumed = new Request(""); +var bodyConsumed = new Request("", initValuesDict); + +test(() => { + assert_equals(noBodyConsumed.body, null, "body's default value is null"); + assert_false(noBodyConsumed.bodyUsed , "bodyUsed is false when request is not disturbed"); + assert_not_equals(bodyConsumed.body, null, "non-null body"); + assert_true(bodyConsumed.body instanceof ReadableStream, "non-null body type"); + assert_false(noBodyConsumed.bodyUsed, "bodyUsed is false when request is not disturbed"); +}, "Request's body: initial state"); + +noBodyConsumed.blob(); +bodyConsumed.blob(); + +test(function() { + assert_false(noBodyConsumed.bodyUsed , "bodyUsed is false when request is not disturbed"); + try { + noBodyConsumed.clone(); + } catch (e) { + assert_unreached("Can use request not disturbed for creating or cloning request"); + } +}, "Request without body cannot be disturbed"); + +test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_throws_js(TypeError, function() { bodyConsumed.clone(); }); +}, "Check cloning a disturbed request"); + +test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_throws_js(TypeError, function() { new Request(bodyConsumed); }); +}, "Check creating a new request from a disturbed request"); + +promise_test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + const originalBody = bodyConsumed.body; + const bodyReplaced = new Request(bodyConsumed, { body: "Replaced body" }); + assert_not_equals(bodyReplaced.body, originalBody, "new request's body is new"); + assert_false(bodyReplaced.bodyUsed, "bodyUsed is false when request is not disturbed"); + return bodyReplaced.text().then(text => { + assert_equals(text, "Replaced body"); + }); +}, "Check creating a new request with a new body from a disturbed request"); + +promise_test(function() { + var bodyRequest = new Request("", initValuesDict); + const originalBody = bodyRequest.body; + assert_false(bodyRequest.bodyUsed , "bodyUsed is false when request is not disturbed"); + var requestFromRequest = new Request(bodyRequest); + assert_true(bodyRequest.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_equals(bodyRequest.body, originalBody, "body should not change"); + assert_not_equals(originalBody, undefined, "body should not be undefined"); + assert_not_equals(originalBody, null, "body should not be null"); + assert_not_equals(requestFromRequest.body, originalBody, "new request's body is new"); + return requestFromRequest.text().then(text => { + assert_equals(text, "Request's body"); + }); +}, "Input request used for creating new request became disturbed"); + +promise_test(() => { + const bodyRequest = new Request("", initValuesDict); + const originalBody = bodyRequest.body; + assert_false(bodyRequest.bodyUsed , "bodyUsed is false when request is not disturbed"); + const requestFromRequest = new Request(bodyRequest, { body : "init body" }); + assert_true(bodyRequest.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_equals(bodyRequest.body, originalBody, "body should not change"); + assert_not_equals(originalBody, undefined, "body should not be undefined"); + assert_not_equals(originalBody, null, "body should not be null"); + assert_not_equals(requestFromRequest.body, originalBody, "new request's body is new"); + + return requestFromRequest.text().then(text => { + assert_equals(text, "init body"); + }); +}, "Input request used for creating new request became disturbed even if body is not used"); + +promise_test(function(test) { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + return promise_rejects_js(test, TypeError, bodyConsumed.blob()); +}, "Check consuming a disturbed request"); + +test(function() { + var req = new Request(URL, {method: 'POST', body: 'hello'}); + assert_false(req.bodyUsed, + 'Request should not be flagged as used if it has not been ' + + 'consumed.'); + assert_throws_js(TypeError, + function() { new Request(req, {method: 'GET'}); }, + 'A get request may not have body.'); + + assert_false(req.bodyUsed, 'After the GET case'); + + assert_throws_js(TypeError, + function() { new Request(req, {method: 'CONNECT'}); }, + 'Request() with a forbidden method must throw.'); + + assert_false(req.bodyUsed, 'After the forbidden method case'); + + var req2 = new Request(req); + assert_true(req.bodyUsed, + 'Request should be flagged as used if it has been consumed.'); +}, 'Request construction failure should not set "bodyUsed"'); diff --git a/test/wpt/tests/fetch/api/request/request-error.any.js b/test/wpt/tests/fetch/api/request/request-error.any.js new file mode 100644 index 0000000..9ec8015 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-error.any.js @@ -0,0 +1,56 @@ +// META: global=window,worker +// META: title=Request error +// META: script=request-error.js + +// badRequestArgTests is from response-error.js +for (const { args, testName } of badRequestArgTests) { + test(() => { + assert_throws_js( + TypeError, + () => new Request(...args), + "Expect TypeError exception" + ); + }, testName); +} + +test(function() { + assert_throws_js( + TypeError, + () => Request("about:blank"), + "Calling Request constructor without 'new' must throw" + ); +}); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from the init request"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var headers = new Headers([]); + var request = new Request(initialRequest, {"headers" : headers}); + assert_false(request.headers.has("Content-Type")); +}, "Request should not get its content-type from the init request if init headers are provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type-Extra", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "text/plain;charset=UTF-8"); +}, "Request should get its content-type from the body if none is provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from init headers if one is provided"); + +test(function() { + var options = {"cache": "only-if-cached", "mode": "same-origin"}; + new Request("test", options); +}, "Request with cache mode: only-if-cached and fetch mode: same-origin"); diff --git a/test/wpt/tests/fetch/api/request/request-error.js b/test/wpt/tests/fetch/api/request/request-error.js new file mode 100644 index 0000000..cf77313 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-error.js @@ -0,0 +1,57 @@ +const badRequestArgTests = [ + { + args: ["", { "window": "http://test.url" }], + testName: "RequestInit's window is not null" + }, + { + args: ["http://:not a valid URL"], + testName: "Input URL is not valid" + }, + { + args: ["http://user:pass@test.url"], + testName: "Input URL has credentials" + }, + { + args: ["", { "mode": "navigate" }], + testName: "RequestInit's mode is navigate" + }, + { + args: ["", { "referrer": "http://:not a valid URL" }], + testName: "RequestInit's referrer is invalid" + }, + { + args: ["", { "method": "IN VALID" }], + testName: "RequestInit's method is invalid" + }, + { + args: ["", { "method": "TRACE" }], + testName: "RequestInit's method is forbidden" + }, + { + args: ["", { "mode": "no-cors", "method": "PUT" }], + testName: "RequestInit's mode is no-cors and method is not simple" + }, + { + args: ["", { "mode": "cors", "cache": "only-if-cached" }], + testName: "RequestInit's cache mode is only-if-cached and mode is not same-origin" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode cors" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "no-cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode no-cors" + } +]; + +badRequestArgTests.push( + ...["referrerPolicy", "mode", "credentials", "cache", "redirect"].map(optionProp => { + const options = {}; + options[optionProp] = "BAD"; + return { + args: ["", options], + testName: `Bad ${optionProp} init parameter value` + }; + }) +); diff --git a/test/wpt/tests/fetch/api/request/request-headers.any.js b/test/wpt/tests/fetch/api/request/request-headers.any.js new file mode 100644 index 0000000..22925e0 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-headers.any.js @@ -0,0 +1,178 @@ +// META: global=window,worker +// META: title=Request Headers + +var validRequestHeaders = [ + ["Content-Type", "OK"], + ["Potato", "OK"], + ["proxy", "OK"], + ["proxya", "OK"], + ["sec", "OK"], + ["secb", "OK"], + ["Set-Cookie2", "OK"], + ["User-Agent", "OK"], +]; +var invalidRequestHeaders = [ + ["Accept-Charset", "KO"], + ["accept-charset", "KO"], + ["ACCEPT-ENCODING", "KO"], + ["Accept-Encoding", "KO"], + ["Access-Control-Request-Headers", "KO"], + ["Access-Control-Request-Method", "KO"], + ["Access-Control-Request-Private-Network", "KO"], + ["Connection", "KO"], + ["Content-Length", "KO"], + ["Cookie", "KO"], + ["Cookie2", "KO"], + ["Date", "KO"], + ["DNT", "KO"], + ["Expect", "KO"], + ["Host", "KO"], + ["Keep-Alive", "KO"], + ["Origin", "KO"], + ["Referer", "KO"], + ["Set-Cookie", "KO"], + ["TE", "KO"], + ["Trailer", "KO"], + ["Transfer-Encoding", "KO"], + ["Upgrade", "KO"], + ["Via", "KO"], + ["Proxy-", "KO"], + ["proxy-a", "KO"], + ["Sec-", "KO"], + ["sec-b", "KO"], +]; + +var validRequestNoCorsHeaders = [ + ["Accept", "OK"], + ["Accept-Language", "OK"], + ["content-language", "OK"], + ["content-type", "application/x-www-form-urlencoded"], + ["content-type", "application/x-www-form-urlencoded;charset=UTF-8"], + ["content-type", "multipart/form-data"], + ["content-type", "multipart/form-data;charset=UTF-8"], + ["content-TYPE", "text/plain"], + ["CONTENT-type", "text/plain;charset=UTF-8"], +]; +var invalidRequestNoCorsHeaders = [ + ["Content-Type", "KO"], + ["Potato", "KO"], + ["proxy", "KO"], + ["proxya", "KO"], + ["sec", "KO"], + ["secb", "KO"], + ["Empty-Value", ""], +]; + +validRequestHeaders.forEach(function(header) { + test(function() { + var request = new Request(""); + request.headers.set(header[0], header[1]); + assert_equals(request.headers.get(header[0]), header[1]); + }, "Adding valid request header \"" + header[0] + ": " + header[1] + "\""); +}); +invalidRequestHeaders.forEach(function(header) { + test(function() { + var request = new Request(""); + request.headers.set(header[0], header[1]); + assert_equals(request.headers.get(header[0]), null); + }, "Adding invalid request header \"" + header[0] + ": " + header[1] + "\""); +}); + +validRequestNoCorsHeaders.forEach(function(header) { + test(function() { + var requestNoCors = new Request("", {"mode": "no-cors"}); + requestNoCors.headers.set(header[0], header[1]); + assert_equals(requestNoCors.headers.get(header[0]), header[1]); + }, "Adding valid no-cors request header \"" + header[0] + ": " + header[1] + "\""); +}); +invalidRequestNoCorsHeaders.forEach(function(header) { + test(function() { + var requestNoCors = new Request("", {"mode": "no-cors"}); + requestNoCors.headers.set(header[0], header[1]); + assert_equals(requestNoCors.headers.get(header[0]), null); + }, "Adding invalid no-cors request header \"" + header[0] + ": " + header[1] + "\""); +}); + +test(function() { + var headers = new Headers([["Cookie2", "potato"]]); + var request = new Request("", {"headers": headers}); + assert_equals(request.headers.get("Cookie2"), null); +}, "Check that request constructor is filtering headers provided as init parameter"); + +test(function() { + var headers = new Headers([["Content-Type", "potato"]]); + var request = new Request("", {"headers": headers, "mode": "no-cors"}); + assert_equals(request.headers.get("Content-Type"), null); +}, "Check that no-cors request constructor is filtering headers provided as init parameter"); + +test(function() { + var headers = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers": headers}); + var request = new Request(initialRequest, {"mode": "no-cors"}); + assert_equals(request.headers.get("Content-Type"), null); +}, "Check that no-cors request constructor is filtering headers provided as part of request parameter"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from the init request"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var headers = new Headers([]); + var request = new Request(initialRequest, {"headers" : headers}); + assert_false(request.headers.has("Content-Type")); +}, "Request should not get its content-type from the init request if init headers are provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type-Extra", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "text/plain;charset=UTF-8"); +}, "Request should get its content-type from the body if none is provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from init headers if one is provided"); + +test(function() { + var array = [["hello", "worldAHH"]]; + var object = {"hello": 'worldOOH'}; + var headers = new Headers(array); + + assert_equals(headers.get("hello"), "worldAHH"); + + var request1 = new Request("", {"headers": headers}); + var request2 = new Request("", {"headers": array}); + var request3 = new Request("", {"headers": object}); + + assert_equals(request1.headers.get("hello"), "worldAHH"); + assert_equals(request2.headers.get("hello"), "worldAHH"); + assert_equals(request3.headers.get("hello"), "worldOOH"); +}, "Testing request header creations with various objects"); + +promise_test(function(test) { + var request = new Request("", {"headers" : [["Content-Type", ""]], "body" : "this is my plate", "method" : "POST"}); + return request.blob().then(function(blob) { + assert_equals(blob.type, "", "Blob type should be the empty string"); + }); +}, "Testing empty Request Content-Type header"); + +test(function() { + const request1 = new Request(""); + assert_equals(request1.headers, request1.headers); + + const request2 = new Request("", {"headers": {"X-Foo": "bar"}}); + assert_equals(request2.headers, request2.headers); + const headers = request2.headers; + request2.headers.set("X-Foo", "quux"); + assert_equals(headers, request2.headers); + headers.set("X-Other-Header", "baz"); + assert_equals(headers, request2.headers); +}, "Test that Request.headers has the [SameObject] extended attribute"); diff --git a/test/wpt/tests/fetch/api/request/request-init-001.sub.html b/test/wpt/tests/fetch/api/request/request-init-001.sub.html new file mode 100644 index 0000000..cc495a6 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-001.sub.html @@ -0,0 +1,112 @@ + + + + + Request init: simple cases + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-init-002.any.js b/test/wpt/tests/fetch/api/request/request-init-002.any.js new file mode 100644 index 0000000..abb6689 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-002.any.js @@ -0,0 +1,60 @@ +// META: global=window,worker +// META: title=Request init: headers and body + +test(function() { + var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3" + }; + var headers = new Headers(headerDict); + var request = new Request("", { "headers" : headers }) + for (var name in headerDict) { + assert_equals(request.headers.get(name), headerDict[name], + "request's headers has " + name + " : " + headerDict[name]); + } +}, "Initialize Request with headers values"); + +function makeRequestInit(body, method) { + return {"method": method, "body": body}; +} + +function checkRequestInit(body, bodyType, expectedTextBody) { + promise_test(function(test) { + var request = new Request("", makeRequestInit(body, "POST")); + if (body) { + assert_throws_js(TypeError, function() { new Request("", makeRequestInit(body, "GET")); }); + assert_throws_js(TypeError, function() { new Request("", makeRequestInit(body, "HEAD")); }); + } else { + new Request("", makeRequestInit(body, "GET")); // should not throw + } + var reqHeaders = request.headers; + var mime = reqHeaders.get("Content-Type"); + assert_true(!body || (mime && mime.search(bodyType) > -1), "Content-Type header should be \"" + bodyType + "\", not \"" + mime + "\""); + return request.text().then(function(bodyAsText) { + //not equals: cannot guess formData exact value + assert_true( bodyAsText.search(expectedTextBody) > -1, "Retrieve and verify request body"); + }); + }, `Initialize Request's body with "${body}", ${bodyType}`); +} + +var blob = new Blob(["This is a blob"], {type: "application/octet-binary"}); +var formaData = new FormData(); +formaData.append("name", "value"); +var usvString = "This is a USVString" + +checkRequestInit(undefined, undefined, ""); +checkRequestInit(null, null, ""); +checkRequestInit(blob, "application/octet-binary", "This is a blob"); +checkRequestInit(formaData, "multipart/form-data", "name=\"name\"\r\n\r\nvalue"); +checkRequestInit(usvString, "text/plain;charset=UTF-8", "This is a USVString"); +checkRequestInit({toString: () => "hi!"}, "text/plain;charset=UTF-8", "hi!"); + +// Ensure test does not time out in case of missing URLSearchParams support. +if (self.URLSearchParams) { + var urlSearchParams = new URLSearchParams("name=value"); + checkRequestInit(urlSearchParams, "application/x-www-form-urlencoded;charset=UTF-8", "name=value"); +} else { + promise_test(function(test) { + return Promise.reject("URLSearchParams not supported"); + }, "Initialize Request's body with application/x-www-form-urlencoded;charset=UTF-8"); +} diff --git a/test/wpt/tests/fetch/api/request/request-init-003.sub.html b/test/wpt/tests/fetch/api/request/request-init-003.sub.html new file mode 100644 index 0000000..79c91cd --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-003.sub.html @@ -0,0 +1,84 @@ + + + + + Request: init with request or url + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-init-contenttype.any.js b/test/wpt/tests/fetch/api/request/request-init-contenttype.any.js new file mode 100644 index 0000000..18a6969 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-contenttype.any.js @@ -0,0 +1,141 @@ +function requestFromBody(body) { + return new Request( + "https://example.com", + { + method: "POST", + body, + duplex: "half", + }, + ); +} + +test(() => { + const request = requestFromBody(undefined); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with empty body"); + +test(() => { + const blob = new Blob([]); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), "a/b; c=d"); +}, "Default Content-Type for Request with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const request = requestFromBody(buffer); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with buffer source body"); + +promise_test(async () => { + const formData = new FormData(); + formData.append("a", "b"); + const request = requestFromBody(formData); + const boundary = (await request.text()).split("\r\n")[0].slice(2); + assert_equals( + request.headers.get("Content-Type"), + `multipart/form-data; boundary=${boundary}`, + ); +}, "Default Content-Type for Request with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const request = requestFromBody(usp); + assert_equals( + request.headers.get("Content-Type"), + "application/x-www-form-urlencoded;charset=UTF-8", + ); +}, "Default Content-Type for Request with URLSearchParams body"); + +test(() => { + const request = requestFromBody(""); + assert_equals( + request.headers.get("Content-Type"), + "text/plain;charset=UTF-8", + ); +}, "Default Content-Type for Request with string body"); + +test(() => { + const stream = new ReadableStream(); + const request = requestFromBody(stream); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with ReadableStream body"); + +// ----------------------------------------------------------------------------- + +const OVERRIDE_MIME = "test/only; mime=type"; + +function requestFromBodyWithOverrideMime(body) { + return new Request( + "https://example.com", + { + method: "POST", + body, + headers: { "Content-Type": OVERRIDE_MIME }, + duplex: "half", + }, + ); +} + +test(() => { + const request = requestFromBodyWithOverrideMime(undefined); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with empty body"); + +test(() => { + const blob = new Blob([]); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const request = requestFromBodyWithOverrideMime(buffer); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with buffer source body"); + +test(() => { + const formData = new FormData(); + const request = requestFromBodyWithOverrideMime(formData); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const request = requestFromBodyWithOverrideMime(usp); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with URLSearchParams body"); + +test(() => { + const request = requestFromBodyWithOverrideMime(""); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with string body"); + +test(() => { + const stream = new ReadableStream(); + const request = requestFromBodyWithOverrideMime(stream); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with ReadableStream body"); diff --git a/test/wpt/tests/fetch/api/request/request-init-priority.any.js b/test/wpt/tests/fetch/api/request/request-init-priority.any.js new file mode 100644 index 0000000..eb5073c --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-priority.any.js @@ -0,0 +1,26 @@ +var priorities = ["high", + "low", + "auto" + ]; + +for (idx in priorities) { + test(() => { + new Request("", {priority: priorities[idx]}); + }, "new Request() with a '" + priorities[idx] + "' priority does not throw an error"); +} + +test(() => { + assert_throws_js(TypeError, () => { + new Request("", {priority: 'invalid'}); + }, "a new Request() must throw a TypeError if RequestInit's priority is an invalid value"); +}, "new Request() throws a TypeError if any of RequestInit's members' values are invalid"); + +for (idx in priorities) { + promise_test(function(t) { + return fetch('hello.txt', { priority: priorities[idx] }); + }, "fetch() with a '" + priorities[idx] + "' priority completes successfully"); +} + +promise_test(function(t) { + return promise_rejects_js(t, TypeError, fetch('hello.txt', { priority: 'invalid' })); +}, "fetch() with an invalid priority returns a rejected promise with a TypeError"); diff --git a/test/wpt/tests/fetch/api/request/request-init-stream.any.js b/test/wpt/tests/fetch/api/request/request-init-stream.any.js new file mode 100644 index 0000000..f0ae441 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-init-stream.any.js @@ -0,0 +1,147 @@ +// META: global=window,worker + +"use strict"; + +const duplex = "half"; +const method = "POST"; + +test(() => { + const body = new ReadableStream(); + const request = new Request("...", { method, body, duplex }); + assert_equals(request.body, body); +}, "Constructing a Request with a stream holds the original object."); + +test((t) => { + const body = new ReadableStream(); + body.getReader(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which getReader() is called"); + +test((t) => { + const body = new ReadableStream(); + body.getReader().read(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which read() is called"); + +promise_test(async (t) => { + const body = new ReadableStream({ pull: c => c.enqueue(new Uint8Array()) }); + const reader = body.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which read() and releaseLock() are called"); + +test((t) => { + const request = new Request("...", { method: "POST", body: "..." }); + request.body.getReader(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which body.getReader() is called"); + +test((t) => { + const request = new Request("...", { method: "POST", body: "..." }); + request.body.getReader().read(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which body.getReader().read() is called"); + +promise_test(async (t) => { + const request = new Request("...", { method: "POST", body: "..." }); + const reader = request.body.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which read() and releaseLock() are called"); + +test((t) => { + new Request("...", { method, body: null }); +}, "It is OK to omit .duplex when the body is null."); + +test((t) => { + new Request("...", { method, body: "..." }); +}, "It is OK to omit .duplex when the body is a string."); + +test((t) => { + new Request("...", { method, body: new Uint8Array(3) }); +}, "It is OK to omit .duplex when the body is a Uint8Array."); + +test((t) => { + new Request("...", { method, body: new Blob([]) }); +}, "It is OK to omit .duplex when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + assert_throws_js(TypeError, + () => new Request("...", { method, body })); +}, "It is error to omit .duplex when the body is a ReadableStream."); + +test((t) => { + new Request("...", { method, body: null, duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is null."); + +test((t) => { + new Request("...", { method, body: "...", duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a string."); + +test((t) => { + new Request("...", { method, body: new Uint8Array(3), duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a Uint8Array."); + +test((t) => { + new Request("...", { method, body: new Blob([]), duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + new Request("...", { method, body, duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a ReadableStream."); + +test((t) => { + const body = null; + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is null."); + +test((t) => { + const body = "..."; + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a string."); + +test((t) => { + const body = new Uint8Array(3); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a Uint8Array."); + +test((t) => { + const body = new Blob([]); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a ReadableStream."); + +test((t) => { + const body = new ReadableStream(); + const duplex = "half"; + const req1 = new Request("...", { method, body, duplex }); + const req2 = new Request(req1); +}, "It is OK to omit duplex when init.body is not given and input.body is given."); + diff --git a/test/wpt/tests/fetch/api/request/request-keepalive-quota.html b/test/wpt/tests/fetch/api/request/request-keepalive-quota.html new file mode 100644 index 0000000..548ab38 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-keepalive-quota.html @@ -0,0 +1,97 @@ + + + + + Request Keepalive Quota Tests + + + + + + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-keepalive.any.js b/test/wpt/tests/fetch/api/request/request-keepalive.any.js new file mode 100644 index 0000000..cb4506d --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-keepalive.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker +// META: title=Request keepalive +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +test(() => { + assert_false(new Request('/').keepalive, 'default'); + assert_true(new Request('/', {keepalive: true}).keepalive, 'true'); + assert_false(new Request('/', {keepalive: false}).keepalive, 'false'); + assert_true(new Request('/', {keepalive: 1}).keepalive, 'truish'); + assert_false(new Request('/', {keepalive: 0}).keepalive, 'falsy'); +}, 'keepalive flag'); + +test(() => { + const init = {method: 'POST', keepalive: true, body: new ReadableStream()}; + assert_throws_js(TypeError, () => {new Request('/', init)}); +}, 'keepalive flag with stream body'); diff --git a/test/wpt/tests/fetch/api/request/request-reset-attributes.https.html b/test/wpt/tests/fetch/api/request/request-reset-attributes.https.html new file mode 100644 index 0000000..7be3608 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-reset-attributes.https.html @@ -0,0 +1,96 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/api/request/request-structure.any.js b/test/wpt/tests/fetch/api/request/request-structure.any.js new file mode 100644 index 0000000..5e78553 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-structure.any.js @@ -0,0 +1,143 @@ +// META: global=window,worker +// META: title=Request structure + +var request = new Request(""); +var methods = ["clone", + //Request implements Body + "arrayBuffer", + "blob", + "formData", + "json", + "text" + ]; +var attributes = ["method", + "url", + "headers", + "destination", + "referrer", + "referrerPolicy", + "mode", + "credentials", + "cache", + "redirect", + "integrity", + "isReloadNavigation", + "isHistoryNavigation", + "duplex", + //Request implements Body + "bodyUsed" + ]; +var internalAttributes = ["priority", + "internalpriority", + "blocking" + ]; + +function isReadOnly(request, attributeToCheck) { + var defaultValue = undefined; + var newValue = undefined; + switch (attributeToCheck) { + case "method": + defaultValue = "GET"; + newValue = "POST"; + break; + + case "url": + //default value is base url + //i.e http://example.com/fetch/api/request-structure.html + newValue = "http://url.test"; + break; + + case "headers": + request.headers = new Headers ({"name":"value"}); + assert_false(request.headers.has("name"), "Headers attribute is read only"); + return; + + case "destination": + defaultValue = ""; + newValue = "worker"; + break; + + case "referrer": + defaultValue = "about:client"; + newValue = "http://url.test"; + break; + + case "referrerPolicy": + defaultValue = ""; + newValue = "unsafe-url"; + break; + + case "mode": + defaultValue = "cors"; + newValue = "navigate"; + break; + + case "credentials": + defaultValue = "same-origin"; + newValue = "cors"; + break; + + case "cache": + defaultValue = "default"; + newValue = "reload"; + break; + + case "redirect": + defaultValue = "follow"; + newValue = "manual"; + break; + + case "integrity": + newValue = "CannotWriteIntegrity"; + break; + + case "bodyUsed": + defaultValue = false; + newValue = true; + break; + + case "isReloadNavigation": + defaultValue = false; + newValue = true; + break; + + case "isHistoryNavigation": + defaultValue = false; + newValue = true; + break; + + case "duplex": + defaultValue = "half"; + newValue = "full"; + break; + + default: + return; + } + + request[attributeToCheck] = newValue; + if (defaultValue === undefined) + assert_not_equals(request[attributeToCheck], newValue, "Attribute " + attributeToCheck + " is read only"); + else + assert_equals(request[attributeToCheck], defaultValue, + "Attribute " + attributeToCheck + " is read only. Default value is " + defaultValue); +} + +for (var idx in methods) { + test(function() { + assert_true(methods[idx] in request, "request has " + methods[idx] + " method"); + }, "Request has " + methods[idx] + " method"); +} + +for (var idx in attributes) { + test(function() { + assert_true(attributes[idx] in request, "request has " + attributes[idx] + " attribute"); + isReadOnly(request, attributes[idx]); + }, "Check " + attributes[idx] + " attribute"); +} + +for (var idx in internalAttributes) { + test(function() { + assert_false(internalAttributes[idx] in request, "request does not expose " + internalAttributes[idx] + " attribute"); + }, "Request does not expose " + internalAttributes[idx] + " attribute"); +} \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/request/resources/cache.py b/test/wpt/tests/fetch/api/request/resources/cache.py new file mode 100644 index 0000000..ca0bd64 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/resources/cache.py @@ -0,0 +1,67 @@ +from wptserve.utils import isomorphic_decode + +def main(request, response): + token = request.GET.first(b"token", None) + if b"querystate" in request.GET: + from json import JSONEncoder + response.headers.set(b"Content-Type", b"text/plain") + return JSONEncoder().encode(request.server.stash.take(token)) + content = request.GET.first(b"content", None) + tag = request.GET.first(b"tag", None) + date = request.GET.first(b"date", None) + expires = request.GET.first(b"expires", None) + vary = request.GET.first(b"vary", None) + cc = request.GET.first(b"cache_control", None) + redirect = request.GET.first(b"redirect", None) + inm = request.headers.get(b"If-None-Match", None) + ims = request.headers.get(b"If-Modified-Since", None) + pragma = request.headers.get(b"Pragma", None) + cache_control = request.headers.get(b"Cache-Control", None) + ignore = b"ignore" in request.GET + + if tag: + tag = b'"%s"' % tag + + server_state = request.server.stash.take(token) + if not server_state: + server_state = [] + state = dict() + if not ignore: + if inm: + state[u"If-None-Match"] = isomorphic_decode(inm) + if ims: + state[u"If-Modified-Since"] = isomorphic_decode(ims) + if pragma: + state[u"Pragma"] = isomorphic_decode(pragma) + if cache_control: + state[u"Cache-Control"] = isomorphic_decode(cache_control) + server_state.append(state) + request.server.stash.put(token, server_state) + + if tag: + response.headers.set(b"ETag", b'%s' % tag) + elif date: + response.headers.set(b"Last-Modified", date) + if expires: + response.headers.set(b"Expires", expires) + if vary: + response.headers.set(b"Vary", vary) + if cc: + response.headers.set(b"Cache-Control", cc) + + # The only-if-cached redirect tests wants CORS to be okay, the other tests + # are all same-origin anyways and don't care. + response.headers.set(b"Access-Control-Allow-Origin", b"*") + + if redirect: + response.headers.set(b"Location", redirect) + response.status = (302, b"Redirect") + return b"" + elif ((inm is not None and inm == tag) or + (ims is not None and ims == date)): + response.status = (304, b"Not Modified") + return b"" + else: + response.status = (200, b"OK") + response.headers.set(b"Content-Type", b"text/plain") + return content diff --git a/test/wpt/tests/fetch/api/request/resources/hello.txt b/test/wpt/tests/fetch/api/request/resources/hello.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/resources/hello.txt @@ -0,0 +1 @@ +hello diff --git a/test/wpt/tests/fetch/api/request/resources/request-reset-attributes-worker.js b/test/wpt/tests/fetch/api/request/resources/request-reset-attributes-worker.js new file mode 100644 index 0000000..4b264ca --- /dev/null +++ b/test/wpt/tests/fetch/api/request/resources/request-reset-attributes-worker.js @@ -0,0 +1,19 @@ +self.addEventListener('fetch', (event) => { + const params = new URL(event.request.url).searchParams; + if (params.has('ignore')) { + return; + } + if (!params.has('name')) { + event.respondWith(Promise.reject(TypeError('No name is provided.'))); + return; + } + + const name = params.get('name'); + const old_attribute = event.request[name]; + // If any of |init|'s member is present... + const init = {cache: 'no-store'} + const new_attribute = (new Request(event.request, init))[name]; + + event.respondWith( + new Response(`old: ${old_attribute}, new: ${new_attribute}`)); + }); diff --git a/test/wpt/tests/fetch/api/request/url-encoding.html b/test/wpt/tests/fetch/api/request/url-encoding.html new file mode 100644 index 0000000..31c1ed3 --- /dev/null +++ b/test/wpt/tests/fetch/api/request/url-encoding.html @@ -0,0 +1,25 @@ + + +Fetch: URL encoding + + + diff --git a/test/wpt/tests/fetch/api/resources/authentication.py b/test/wpt/tests/fetch/api/resources/authentication.py new file mode 100644 index 0000000..8b6b00b --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/authentication.py @@ -0,0 +1,14 @@ +def main(request, response): + user = request.auth.username + password = request.auth.password + + if user == b"user" and password == b"password": + return b"Authentication done" + + realm = b"test" + if b"realm" in request.GET: + realm = request.GET.first(b"realm") + + return ((401, b"Unauthorized"), + [(b"WWW-Authenticate", b'Basic realm="' + realm + b'"')], + b"Please login with credentials 'user' and 'password'") diff --git a/test/wpt/tests/fetch/api/resources/bad-chunk-encoding.py b/test/wpt/tests/fetch/api/resources/bad-chunk-encoding.py new file mode 100644 index 0000000..94a77ad --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/bad-chunk-encoding.py @@ -0,0 +1,13 @@ +import time + +def main(request, response): + delay = float(request.GET.first(b"ms", 1000)) / 1E3 + count = int(request.GET.first(b"count", 50)) + time.sleep(delay) + response.headers.set(b"Transfer-Encoding", b"chunked") + response.write_status_headers() + time.sleep(delay) + for i in range(count): + response.writer.write_content(b"a\r\nTEST_CHUNK\r\n") + time.sleep(delay) + response.writer.write_content(b"garbage") diff --git a/test/wpt/tests/fetch/api/resources/basic.html b/test/wpt/tests/fetch/api/resources/basic.html new file mode 100644 index 0000000..e23afd4 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/basic.html @@ -0,0 +1,5 @@ + + diff --git a/test/wpt/tests/fetch/api/resources/cache.py b/test/wpt/tests/fetch/api/resources/cache.py new file mode 100644 index 0000000..4de751e --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/cache.py @@ -0,0 +1,18 @@ +ETAG = b'"123abc"' +CONTENT_TYPE = b"text/plain" +CONTENT = b"lorem ipsum dolor sit amet" + + +def main(request, response): + # let caching kick in if possible (conditional GET) + etag = request.headers.get(b"If-None-Match", None) + if etag == ETAG: + response.headers.set(b"X-HTTP-STATUS", 304) + response.status = (304, b"Not Modified") + return b"" + + # cache miss, so respond with the actual content + response.status = (200, b"OK") + response.headers.set(b"ETag", ETAG) + response.headers.set(b"Content-Type", CONTENT_TYPE) + return CONTENT diff --git a/test/wpt/tests/fetch/api/resources/clean-stash.py b/test/wpt/tests/fetch/api/resources/clean-stash.py new file mode 100644 index 0000000..ee8c69a --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/clean-stash.py @@ -0,0 +1,6 @@ +def main(request, response): + token = request.GET.first(b"token") + if request.server.stash.take(token) is not None: + return b"1" + else: + return b"0" diff --git a/test/wpt/tests/fetch/api/resources/cors-top.txt b/test/wpt/tests/fetch/api/resources/cors-top.txt new file mode 100644 index 0000000..83a3157 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/cors-top.txt @@ -0,0 +1 @@ +top \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/resources/cors-top.txt.headers b/test/wpt/tests/fetch/api/resources/cors-top.txt.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/cors-top.txt.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/wpt/tests/fetch/api/resources/data.json b/test/wpt/tests/fetch/api/resources/data.json new file mode 100644 index 0000000..76519fa --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/data.json @@ -0,0 +1 @@ +{"key": "value"} diff --git a/test/wpt/tests/fetch/api/resources/dump-authorization-header.py b/test/wpt/tests/fetch/api/resources/dump-authorization-header.py new file mode 100644 index 0000000..a651aeb --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/dump-authorization-header.py @@ -0,0 +1,14 @@ +def main(request, response): + headers = [(b"Content-Type", "text/html"), + (b"Cache-Control", b"no-cache")] + + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Headers", b'Authorization')) + + if b"authorization" in request.headers: + return 200, headers, request.headers.get(b"Authorization") + return 200, headers, "none" diff --git a/test/wpt/tests/fetch/api/resources/echo-content.h2.py b/test/wpt/tests/fetch/api/resources/echo-content.h2.py new file mode 100644 index 0000000..0be3ece --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/echo-content.h2.py @@ -0,0 +1,7 @@ +def handle_headers(frame, request, response): + response.status = 200 + response.headers.update([('Content-Type', 'text/plain')]) + response.write_status_headers() + +def handle_data(frame, request, response): + response.writer.write_data(frame.data) diff --git a/test/wpt/tests/fetch/api/resources/echo-content.py b/test/wpt/tests/fetch/api/resources/echo-content.py new file mode 100644 index 0000000..5e137e1 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/echo-content.py @@ -0,0 +1,12 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + + headers = [(b"X-Request-Method", isomorphic_encode(request.method)), + (b"X-Request-Content-Length", request.headers.get(b"Content-Length", b"NO")), + (b"X-Request-Content-Type", request.headers.get(b"Content-Type", b"NO")), + # Avoid any kind of content sniffing on the response. + (b"Content-Type", b"text/plain")] + content = request.body + + return headers, content diff --git a/test/wpt/tests/fetch/api/resources/empty.txt b/test/wpt/tests/fetch/api/resources/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/api/resources/infinite-slow-response.py b/test/wpt/tests/fetch/api/resources/infinite-slow-response.py new file mode 100644 index 0000000..a26cd80 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/infinite-slow-response.py @@ -0,0 +1,35 @@ +import time + + +def url_dir(request): + return u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + + +def stash_write(request, key, value): + """Write to the stash, overwriting any previous value""" + request.server.stash.take(key, url_dir(request)) + request.server.stash.put(key, value, url_dir(request)) + + +def main(request, response): + stateKey = request.GET.first(b"stateKey", b"") + abortKey = request.GET.first(b"abortKey", b"") + + if stateKey: + stash_write(request, stateKey, 'open') + + response.headers.set(b"Content-type", b"text/plain") + response.write_status_headers() + + # Writing an initial 2k so browsers realise it's there. *shrug* + response.writer.write(b"." * 2048) + + while True: + if not response.writer.write(b"."): + break + if abortKey and request.server.stash.take(abortKey, url_dir(request)): + break + time.sleep(0.01) + + if stateKey: + stash_write(request, stateKey, 'closed') diff --git a/test/wpt/tests/fetch/api/resources/inspect-headers.py b/test/wpt/tests/fetch/api/resources/inspect-headers.py new file mode 100644 index 0000000..9ed566e --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/inspect-headers.py @@ -0,0 +1,24 @@ +def main(request, response): + headers = [] + if b"headers" in request.GET: + checked_headers = request.GET.first(b"headers").split(b"|") + for header in checked_headers: + if header in request.headers: + headers.append((b"x-request-" + header, request.headers.get(header, b""))) + + if b"cors" in request.GET: + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + headers.append((b"Access-Control-Allow-Methods", b"GET, POST, HEAD")) + exposed_headers = [b"x-request-" + header for header in checked_headers] + headers.append((b"Access-Control-Expose-Headers", b", ".join(exposed_headers))) + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + else: + headers.append((b"Access-Control-Allow-Headers", b", ".join(request.headers))) + + headers.append((b"content-type", b"text/plain")) + return headers, b"" diff --git a/test/wpt/tests/fetch/api/resources/keepalive-helper.js b/test/wpt/tests/fetch/api/resources/keepalive-helper.js new file mode 100644 index 0000000..ad1d4b2 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/keepalive-helper.js @@ -0,0 +1,99 @@ +// Utility functions to help testing keepalive requests. + +// Returns a URL to an iframe that loads a keepalive URL on iframe loaded. +// +// The keepalive URL points to a target that stores `token`. The token will then +// be posted back on iframe loaded to the parent document. +// `method` defaults to GET. +// `frameOrigin` to specify the origin of the iframe to load. If not set, +// default to a different site origin. +// `requestOrigin` to specify the origin of the fetch request target. +// `sendOn` to specify the name of the event when the keepalive request should +// be sent instead of the default 'load'. +// `mode` to specify the fetch request's CORS mode. +// `disallowOrigin` to ask the iframe to set up a server that forbids CORS +// requests. +function getKeepAliveIframeUrl(token, method, { + frameOrigin = 'DEFAULT', + requestOrigin = '', + sendOn = 'load', + mode = 'cors', + disallowOrigin = false +} = {}) { + const https = location.protocol.startsWith('https'); + frameOrigin = frameOrigin === 'DEFAULT' ? + get_host_info()[https ? 'HTTPS_NOTSAMESITE_ORIGIN' : 'HTTP_NOTSAMESITE_ORIGIN'] : + frameOrigin; + return `${frameOrigin}/fetch/api/resources/keepalive-iframe.html?` + + `token=${token}&` + + `method=${method}&` + + `sendOn=${sendOn}&` + + `mode=${mode}&` + (disallowOrigin ? `disallowOrigin=1&` : ``) + + `origin=${requestOrigin}`; +} + +// Returns a different-site URL to an iframe that loads a keepalive URL. +// +// By default, the keepalive URL points to a target that redirects to another +// same-origin destination storing `token`. The token will then be posted back +// to parent document. +// +// The URL redirects can be customized from `origin1` to `origin2` if provided. +// Sets `withPreflight` to true to get URL enabling preflight. +function getKeepAliveAndRedirectIframeUrl( + token, origin1, origin2, withPreflight) { + const https = location.protocol.startsWith('https'); + const frameOrigin = + get_host_info()[https ? 'HTTPS_NOTSAMESITE_ORIGIN' : 'HTTP_NOTSAMESITE_ORIGIN']; + return `${frameOrigin}/fetch/api/resources/keepalive-redirect-iframe.html?` + + `token=${token}&` + + `origin1=${origin1}&` + + `origin2=${origin2}&` + (withPreflight ? `with-headers` : ``); +} + +async function iframeLoaded(iframe) { + return new Promise((resolve) => iframe.addEventListener('load', resolve)); +} + +// Obtains the token from the message posted by iframe after loading +// `getKeepAliveAndRedirectIframeUrl()`. +async function getTokenFromMessage() { + return new Promise((resolve) => { + window.addEventListener('message', (event) => { + resolve(event.data); + }, {once: true}); + }); +} + +// Tells if `token` has been stored in the server. +async function queryToken(token) { + const response = await fetch(`../resources/stash-take.py?key=${token}`); + const json = await response.json(); + return json; +} + +// In order to parallelize the work, we are going to have an async_test +// for the rest of the work. Note that we want the serialized behavior +// for the steps so far, so we don't want to make the entire test case +// an async_test. +function assertStashedTokenAsync(testName, token, {shouldPass = true} = {}) { + async_test((test) => { + new Promise((resolve) => test.step_timeout(resolve, 3000)) + .then(() => { + return queryToken(token); + }) + .then((result) => { + assert_equals(result, 'on'); + }) + .then(() => { + test.done(); + }) + .catch(test.step_func((e) => { + if (shouldPass) { + assert_unreached(e); + } else { + test.done(); + } + })); + }, testName); +} diff --git a/test/wpt/tests/fetch/api/resources/keepalive-iframe.html b/test/wpt/tests/fetch/api/resources/keepalive-iframe.html new file mode 100644 index 0000000..335a1f8 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/keepalive-iframe.html @@ -0,0 +1,21 @@ + + + + + diff --git a/test/wpt/tests/fetch/api/resources/keepalive-redirect-iframe.html b/test/wpt/tests/fetch/api/resources/keepalive-redirect-iframe.html new file mode 100644 index 0000000..fdee00f --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/keepalive-redirect-iframe.html @@ -0,0 +1,23 @@ + + + + + diff --git a/test/wpt/tests/fetch/api/resources/keepalive-redirect-window.html b/test/wpt/tests/fetch/api/resources/keepalive-redirect-window.html new file mode 100644 index 0000000..c186507 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/keepalive-redirect-window.html @@ -0,0 +1,42 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/api/resources/method.py b/test/wpt/tests/fetch/api/resources/method.py new file mode 100644 index 0000000..c1a111b --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/method.py @@ -0,0 +1,18 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + headers = [] + if b"cors" in request.GET: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + headers.append((b"Access-Control-Allow-Methods", b"GET, POST, PUT, FOO")) + headers.append((b"Access-Control-Allow-Headers", b"x-test, x-foo")) + headers.append((b"Access-Control-Expose-Headers", b"x-request-method")) + + headers.append((b"x-request-method", isomorphic_encode(request.method))) + headers.append((b"x-request-content-type", request.headers.get(b"Content-Type", b"NO"))) + headers.append((b"x-request-content-length", request.headers.get(b"Content-Length", b"NO"))) + headers.append((b"x-request-content-encoding", request.headers.get(b"Content-Encoding", b"NO"))) + headers.append((b"x-request-content-language", request.headers.get(b"Content-Language", b"NO"))) + headers.append((b"x-request-content-location", request.headers.get(b"Content-Location", b"NO"))) + return headers, request.body diff --git a/test/wpt/tests/fetch/api/resources/preflight.py b/test/wpt/tests/fetch/api/resources/preflight.py new file mode 100644 index 0000000..f983ef9 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/preflight.py @@ -0,0 +1,78 @@ +def main(request, response): + headers = [(b"Content-Type", b"text/plain")] + stashed_data = {b'control_request_headers': b"", b'preflight': b"0", b'preflight_referrer': b""} + + token = None + if b"token" in request.GET: + token = request.GET.first(b"token") + + if b"origin" in request.GET: + for origin in request.GET[b'origin'].split(b", "): + headers.append((b"Access-Control-Allow-Origin", origin)) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + + if b"clear-stash" in request.GET: + if request.server.stash.take(token) is not None: + return headers, b"1" + else: + return headers, b"0" + + if b"credentials" in request.GET: + headers.append((b"Access-Control-Allow-Credentials", b"true")) + + if request.method == u"OPTIONS": + if not b"Access-Control-Request-Method" in request.headers: + response.set_error(400, u"No Access-Control-Request-Method header") + return b"ERROR: No access-control-request-method in preflight!" + + if request.headers.get(b"Accept", b"") != b"*/*": + response.set_error(400, u"Request does not have 'Accept: */*' header") + return b"ERROR: Invalid access in preflight!" + + if b"control_request_headers" in request.GET: + stashed_data[b'control_request_headers'] = request.headers.get(b"Access-Control-Request-Headers", None) + + if b"max_age" in request.GET: + headers.append((b"Access-Control-Max-Age", request.GET[b'max_age'])) + + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + + if b"allow_methods" in request.GET: + headers.append((b"Access-Control-Allow-Methods", request.GET[b'allow_methods'])) + + preflight_status = 200 + if b"preflight_status" in request.GET: + preflight_status = int(request.GET.first(b"preflight_status")) + + stashed_data[b'preflight'] = b"1" + stashed_data[b'preflight_referrer'] = request.headers.get(b"Referer", b"") + stashed_data[b'preflight_user_agent'] = request.headers.get(b"User-Agent", b"") + if token: + request.server.stash.put(token, stashed_data) + + return preflight_status, headers, b"" + + + if token: + data = request.server.stash.take(token) + if data: + stashed_data = data + + if b"checkUserAgentHeaderInPreflight" in request.GET and request.headers.get(b"User-Agent") != stashed_data[b'preflight_user_agent']: + return 400, headers, b"ERROR: No user-agent header in preflight" + + #use x-* headers for returning value to bodyless responses + headers.append((b"Access-Control-Expose-Headers", b"x-did-preflight, x-control-request-headers, x-referrer, x-preflight-referrer, x-origin")) + headers.append((b"x-did-preflight", stashed_data[b'preflight'])) + if stashed_data[b'control_request_headers'] != None: + headers.append((b"x-control-request-headers", stashed_data[b'control_request_headers'])) + headers.append((b"x-preflight-referrer", stashed_data[b'preflight_referrer'])) + headers.append((b"x-referrer", request.headers.get(b"Referer", b""))) + headers.append((b"x-origin", request.headers.get(b"Origin", b""))) + + if token: + request.server.stash.put(token, stashed_data) + + return headers, b"" diff --git a/test/wpt/tests/fetch/api/resources/redirect-empty-location.py b/test/wpt/tests/fetch/api/resources/redirect-empty-location.py new file mode 100644 index 0000000..1a5f7fe --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/redirect-empty-location.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Location", b"")] + return 302, headers, b"" diff --git a/test/wpt/tests/fetch/api/resources/redirect.h2.py b/test/wpt/tests/fetch/api/resources/redirect.h2.py new file mode 100644 index 0000000..6937014 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/redirect.h2.py @@ -0,0 +1,14 @@ +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def handle_headers(frame, request, response): + status = 302 + if b'redirect_status' in request.GET: + status = int(request.GET[b'redirect_status']) + response.status = status + + if b'location' in request.GET: + url = isomorphic_decode(request.GET[b'location']) + response.headers[b'Location'] = isomorphic_encode(url) + + response.headers.update([('Content-Type', 'text/plain')]) + response.write_status_headers() diff --git a/test/wpt/tests/fetch/api/resources/redirect.py b/test/wpt/tests/fetch/api/resources/redirect.py new file mode 100644 index 0000000..d52ab5f --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/redirect.py @@ -0,0 +1,73 @@ +import time + +from urllib.parse import urlencode, urlparse + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def main(request, response): + stashed_data = {b'count': 0, b'preflight': b"0"} + status = 302 + headers = [(b"Content-Type", b"text/plain"), + (b"Cache-Control", b"no-cache"), + (b"Pragma", b"no-cache")] + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + + token = None + if b"token" in request.GET: + token = request.GET.first(b"token") + data = request.server.stash.take(token) + if data: + stashed_data = data + + if request.method == u"OPTIONS": + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + stashed_data[b'preflight'] = b"1" + #Preflight is not redirected: return 200 + if not b"redirect_preflight" in request.GET: + if token: + request.server.stash.put(request.GET.first(b"token"), stashed_data) + return 200, headers, u"" + + if b"redirect_status" in request.GET: + status = int(request.GET[b'redirect_status']) + elif b"redirect_status" in request.POST: + status = int(request.POST[b'redirect_status']) + + stashed_data[b'count'] += 1 + + if b"location" in request.GET: + url = isomorphic_decode(request.GET[b'location']) + if b"simple" not in request.GET: + scheme = urlparse(url).scheme + if scheme == u"" or scheme == u"http" or scheme == u"https": + url += u"&" if u'?' in url else u"?" + #keep url parameters in location + url_parameters = {} + for item in request.GET.items(): + url_parameters[isomorphic_decode(item[0])] = isomorphic_decode(item[1][0]) + url += urlencode(url_parameters) + #make sure location changes during redirection loop + url += u"&count=" + str(stashed_data[b'count']) + headers.append((b"Location", isomorphic_encode(url))) + + if b"redirect_referrerpolicy" in request.GET: + headers.append((b"Referrer-Policy", request.GET[b'redirect_referrerpolicy'])) + + if b"delay" in request.GET: + time.sleep(float(request.GET.first(b"delay", 0)) / 1E3) + + if token: + request.server.stash.put(request.GET.first(b"token"), stashed_data) + if b"max_count" in request.GET: + max_count = int(request.GET[b'max_count']) + #stop redirecting and return count + if stashed_data[b'count'] > max_count: + # -1 because the last is not a redirection + return str(stashed_data[b'count'] - 1) + + return status, headers, u"" diff --git a/test/wpt/tests/fetch/api/resources/sandboxed-iframe.html b/test/wpt/tests/fetch/api/resources/sandboxed-iframe.html new file mode 100644 index 0000000..6e5d506 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/sandboxed-iframe.html @@ -0,0 +1,34 @@ + + + + diff --git a/test/wpt/tests/fetch/api/resources/script-with-header.py b/test/wpt/tests/fetch/api/resources/script-with-header.py new file mode 100644 index 0000000..9a9c70e --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/script-with-header.py @@ -0,0 +1,7 @@ +def main(request, response): + headers = [(b"Content-type", request.GET.first(b"mime"))] + if b"content" in request.GET and request.GET.first(b"content") == b"empty": + content = b'' + else: + content = b"console.log('Script loaded')" + return 200, headers, content diff --git a/test/wpt/tests/fetch/api/resources/stash-put.py b/test/wpt/tests/fetch/api/resources/stash-put.py new file mode 100644 index 0000000..0530e1b --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/stash-put.py @@ -0,0 +1,19 @@ +from wptserve.utils import isomorphic_decode + +def main(request, response): + if request.method == u'OPTIONS': + # CORS preflight + response.headers.set(b'Access-Control-Allow-Origin', b'*') + response.headers.set(b'Access-Control-Allow-Methods', b'*') + response.headers.set(b'Access-Control-Allow-Headers', b'*') + return 'done' + + url_dir = u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + key = request.GET.first(b'key') + value = request.GET.first(b'value') + # value here must be a text string. It will be json.dump()'ed in stash-take.py. + request.server.stash.put(key, isomorphic_decode(value), url_dir) + + if b'disallow_origin' not in request.GET: + response.headers.set(b'Access-Control-Allow-Origin', b'*') + return 'done' diff --git a/test/wpt/tests/fetch/api/resources/stash-take.py b/test/wpt/tests/fetch/api/resources/stash-take.py new file mode 100644 index 0000000..e6db80d --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/stash-take.py @@ -0,0 +1,9 @@ +from wptserve.handlers import json_handler + + +@json_handler +def main(request, response): + dir = u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + key = request.GET.first(b"key") + response.headers.set(b'Access-Control-Allow-Origin', b'*') + return request.server.stash.take(key, dir) diff --git a/test/wpt/tests/fetch/api/resources/status.py b/test/wpt/tests/fetch/api/resources/status.py new file mode 100644 index 0000000..05a59d5 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/status.py @@ -0,0 +1,11 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + code = int(request.GET.first(b"code", 200)) + text = request.GET.first(b"text", b"OMG") + content = request.GET.first(b"content", b"") + type = request.GET.first(b"type", b"") + status = (code, text) + headers = [(b"Content-Type", type), + (b"X-Request-Method", isomorphic_encode(request.method))] + return status, headers, content diff --git a/test/wpt/tests/fetch/api/resources/sw-intercept-abort.js b/test/wpt/tests/fetch/api/resources/sw-intercept-abort.js new file mode 100644 index 0000000..19d4b18 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/sw-intercept-abort.js @@ -0,0 +1,19 @@ +async function messageClient(clientId, message) { + const client = await clients.get(clientId); + client.postMessage(message); +} + +addEventListener('fetch', event => { + let resolve; + const promise = new Promise(r => resolve = r); + + function onAborted() { + messageClient(event.clientId, event.request.signal.reason); + resolve(); + } + + messageClient(event.clientId, 'fetch event has arrived'); + + event.respondWith(promise.then(() => new Response('hello'))); + event.request.signal.addEventListener('abort', onAborted); +}); diff --git a/test/wpt/tests/fetch/api/resources/sw-intercept.js b/test/wpt/tests/fetch/api/resources/sw-intercept.js new file mode 100644 index 0000000..b8166b6 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/sw-intercept.js @@ -0,0 +1,10 @@ +async function broadcast(msg) { + for (const client of await clients.matchAll()) { + client.postMessage(msg); + } +} + +addEventListener('fetch', event => { + event.waitUntil(broadcast(event.request.url)); + event.respondWith(fetch(event.request)); +}); diff --git a/test/wpt/tests/fetch/api/resources/top.txt b/test/wpt/tests/fetch/api/resources/top.txt new file mode 100644 index 0000000..83a3157 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/top.txt @@ -0,0 +1 @@ +top \ No newline at end of file diff --git a/test/wpt/tests/fetch/api/resources/trickle.py b/test/wpt/tests/fetch/api/resources/trickle.py new file mode 100644 index 0000000..99833f1 --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/trickle.py @@ -0,0 +1,15 @@ +import time + +def main(request, response): + delay = float(request.GET.first(b"ms", 500)) / 1E3 + count = int(request.GET.first(b"count", 50)) + # Read request body + request.body + time.sleep(delay) + if not b"notype" in request.GET: + response.headers.set(b"Content-type", b"text/plain") + response.write_status_headers() + time.sleep(delay) + for i in range(count): + response.writer.write_content(b"TEST_TRICKLE\n") + time.sleep(delay) diff --git a/test/wpt/tests/fetch/api/resources/utils.js b/test/wpt/tests/fetch/api/resources/utils.js new file mode 100644 index 0000000..3b20ecc --- /dev/null +++ b/test/wpt/tests/fetch/api/resources/utils.js @@ -0,0 +1,105 @@ +var RESOURCES_DIR = "../resources/"; + +function dirname(path) { + return path.replace(/\/[^\/]*$/, '/') +} + +function checkRequest(request, ExpectedValuesDict) { + for (var attribute in ExpectedValuesDict) { + switch(attribute) { + case "headers": + for (var key in ExpectedValuesDict["headers"].keys()) { + assert_equals(request["headers"].get(key), ExpectedValuesDict["headers"].get(key), + "Check headers attribute has " + key + ":" + ExpectedValuesDict["headers"].get(key)); + } + break; + + case "body": + //for checking body's content, a dedicated asyncronous/promise test should be used + assert_true(request["headers"].has("Content-Type") , "Check request has body using Content-Type header") + break; + + case "method": + case "referrer": + case "referrerPolicy": + case "credentials": + case "cache": + case "redirect": + case "integrity": + case "url": + case "destination": + assert_equals(request[attribute], ExpectedValuesDict[attribute], "Check " + attribute + " attribute") + break; + + default: + break; + } + } +} + +function stringToArray(str) { + var array = new Uint8Array(str.length); + for (var i=0, strLen = str.length; i < strLen; i++) + array[i] = str.charCodeAt(i); + return array; +} + +function encode_utf8(str) +{ + if (self.TextEncoder) + return (new TextEncoder).encode(str); + return stringToArray(unescape(encodeURIComponent(str))); +} + +function validateBufferFromString(buffer, expectedValue, message) +{ + return assert_array_equals(new Uint8Array(buffer !== undefined ? buffer : []), stringToArray(expectedValue), message); +} + +function validateStreamFromString(reader, expectedValue, retrievedArrayBuffer) { + // Passing Uint8Array for byte streams; non-byte streams will simply ignore it + return reader.read(new Uint8Array(64)).then(function(data) { + if (!data.done) { + assert_true(data.value instanceof Uint8Array, "Fetch ReadableStream chunks should be Uint8Array"); + var newBuffer; + if (retrievedArrayBuffer) { + newBuffer = new Uint8Array(data.value.length + retrievedArrayBuffer.length); + newBuffer.set(retrievedArrayBuffer, 0); + newBuffer.set(data.value, retrievedArrayBuffer.length); + } else { + newBuffer = data.value; + } + return validateStreamFromString(reader, expectedValue, newBuffer); + } + validateBufferFromString(retrievedArrayBuffer, expectedValue, "Retrieve and verify stream"); + }); +} + +function validateStreamFromPartialString(reader, expectedValue, retrievedArrayBuffer) { + // Passing Uint8Array for byte streams; non-byte streams will simply ignore it + return reader.read(new Uint8Array(64)).then(function(data) { + if (!data.done) { + assert_true(data.value instanceof Uint8Array, "Fetch ReadableStream chunks should be Uint8Array"); + var newBuffer; + if (retrievedArrayBuffer) { + newBuffer = new Uint8Array(data.value.length + retrievedArrayBuffer.length); + newBuffer.set(retrievedArrayBuffer, 0); + newBuffer.set(data.value, retrievedArrayBuffer.length); + } else { + newBuffer = data.value; + } + return validateStreamFromPartialString(reader, expectedValue, newBuffer); + } + + var string = new TextDecoder("utf-8").decode(retrievedArrayBuffer); + return assert_true(string.search(expectedValue) != -1, "Retrieve and verify stream"); + }); +} + +// From streams tests +function delay(milliseconds) +{ + return new Promise(function(resolve) { + step_timeout(resolve, milliseconds); + }); +} diff --git a/test/wpt/tests/fetch/api/response/json.any.js b/test/wpt/tests/fetch/api/response/json.any.js new file mode 100644 index 0000000..15f050e --- /dev/null +++ b/test/wpt/tests/fetch/api/response/json.any.js @@ -0,0 +1,14 @@ +// See also /xhr/json.any.js + +promise_test(async t => { + const response = await fetch(`data:,\uFEFF{ "b": 1, "a": 2, "b": 3 }`); + const json = await response.json(); + assert_array_equals(Object.keys(json), ["b", "a"]); + assert_equals(json.a, 2); + assert_equals(json.b, 3); +}, "Ensure the correct JSON parser is used"); + +promise_test(async t => { + const response = await fetch("/xhr/resources/utf16-bom.json"); + return promise_rejects_js(t, SyntaxError, response.json()); +}, "Ensure UTF-16 results in an error"); diff --git a/test/wpt/tests/fetch/api/response/many-empty-chunks-crash.html b/test/wpt/tests/fetch/api/response/many-empty-chunks-crash.html new file mode 100644 index 0000000..fe5e7d4 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/many-empty-chunks-crash.html @@ -0,0 +1,14 @@ + + + diff --git a/test/wpt/tests/fetch/api/response/multi-globals/current/current.html b/test/wpt/tests/fetch/api/response/multi-globals/current/current.html new file mode 100644 index 0000000..9bb6e0b --- /dev/null +++ b/test/wpt/tests/fetch/api/response/multi-globals/current/current.html @@ -0,0 +1,3 @@ + +Current page used as a test helper + diff --git a/test/wpt/tests/fetch/api/response/multi-globals/incumbent/incumbent.html b/test/wpt/tests/fetch/api/response/multi-globals/incumbent/incumbent.html new file mode 100644 index 0000000..f63372e --- /dev/null +++ b/test/wpt/tests/fetch/api/response/multi-globals/incumbent/incumbent.html @@ -0,0 +1,16 @@ + +Incumbent page used as a test helper + + + + + diff --git a/test/wpt/tests/fetch/api/response/multi-globals/relevant/relevant.html b/test/wpt/tests/fetch/api/response/multi-globals/relevant/relevant.html new file mode 100644 index 0000000..44f42ed --- /dev/null +++ b/test/wpt/tests/fetch/api/response/multi-globals/relevant/relevant.html @@ -0,0 +1,2 @@ + +Relevant page used as a test helper diff --git a/test/wpt/tests/fetch/api/response/multi-globals/url-parsing.html b/test/wpt/tests/fetch/api/response/multi-globals/url-parsing.html new file mode 100644 index 0000000..5f2f42a --- /dev/null +++ b/test/wpt/tests/fetch/api/response/multi-globals/url-parsing.html @@ -0,0 +1,27 @@ + +Response.redirect URL parsing, with multiple globals in play + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/response/response-body-read-task-handling.html b/test/wpt/tests/fetch/api/response/response-body-read-task-handling.html new file mode 100644 index 0000000..64b0755 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-body-read-task-handling.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/response/response-cancel-stream.any.js b/test/wpt/tests/fetch/api/response/response-cancel-stream.any.js new file mode 100644 index 0000000..91140d1 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-cancel-stream.any.js @@ -0,0 +1,64 @@ +// META: global=window,worker +// META: title=Response consume blob and http bodies +// META: script=../resources/utils.js + +promise_test(function(test) { + return new Response(new Blob([], { "type" : "text/plain" })).body.cancel(); +}, "Cancelling a starting blob Response stream"); + +promise_test(function(test) { + var response = new Response(new Blob(["This is data"], { "type" : "text/plain" })); + var reader = response.body.getReader(); + reader.read(); + return reader.cancel(); +}, "Cancelling a loading blob Response stream"); + +promise_test(function(test) { + var response = new Response(new Blob(["T"], { "type" : "text/plain" })); + var reader = response.body.getReader(); + + var closedPromise = reader.closed.then(function() { + return reader.cancel(); + }); + reader.read().then(function readMore({done, value}) { + if (!done) return reader.read().then(readMore); + }); + return closedPromise; +}, "Cancelling a closed blob Response stream"); + +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(response) { + return response.body.cancel(); + }); +}, "Cancelling a starting Response stream"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(response) { + var reader = response.body.getReader(); + return reader.read().then(function() { + return reader.cancel(); + }); + }); +}, "Cancelling a loading Response stream"); + +promise_test(function() { + async function readAll(reader) { + while (true) { + const {value, done} = await reader.read(); + if (done) + return; + } + } + + return fetch(RESOURCES_DIR + "top.txt").then(function(response) { + var reader = response.body.getReader(); + return readAll(reader).then(() => reader.cancel()); + }); +}, "Cancelling a closed Response stream"); + +promise_test(async () => { + const response = await fetch(RESOURCES_DIR + "top.txt"); + const { body } = response; + await body.cancel(); + assert_equals(body, response.body, ".body should not change after cancellation"); +}, "Accessing .body after canceling it"); diff --git a/test/wpt/tests/fetch/api/response/response-clone-iframe.window.js b/test/wpt/tests/fetch/api/response/response-clone-iframe.window.js new file mode 100644 index 0000000..da54616 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-clone-iframe.window.js @@ -0,0 +1,32 @@ +// Verify that calling Response clone() in a detached iframe doesn't crash. +// Regression test for https://crbug.com/1082688. + +'use strict'; + +promise_test(async () => { + // Wait for the document body to be available. + await new Promise(resolve => { + onload = resolve; + }); + + window.iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.srcdoc = ` + +`; + + await new Promise(resolve => { + onmessage = evt => { + if (evt.data === 'okay') { + resolve(); + } + }; + }); + + // If it got here without crashing, the test passed. +}, 'clone within removed iframe should not crash'); diff --git a/test/wpt/tests/fetch/api/response/response-clone.any.js b/test/wpt/tests/fetch/api/response/response-clone.any.js new file mode 100644 index 0000000..f5cda75 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-clone.any.js @@ -0,0 +1,140 @@ +// META: global=window,worker +// META: title=Response clone +// META: script=../resources/utils.js + +var defaultValues = { "type" : "default", + "url" : "", + "ok" : true, + "status" : 200, + "statusText" : "" +}; + +var response = new Response(); +var clonedResponse = response.clone(); +test(function() { + for (var attributeName in defaultValues) { + var expectedValue = defaultValues[attributeName]; + assert_equals(clonedResponse[attributeName], expectedValue, + "Expect default response." + attributeName + " is " + expectedValue); + } +}, "Check Response's clone with default values, without body"); + +var body = "This is response body"; +var headersInit = { "name" : "value" }; +var responseInit = { "status" : 200, + "statusText" : "GOOD", + "headers" : headersInit +}; +var response = new Response(body, responseInit); +var clonedResponse = response.clone(); +test(function() { + assert_equals(clonedResponse.status, responseInit["status"], + "Expect response.status is " + responseInit["status"]); + assert_equals(clonedResponse.statusText, responseInit["statusText"], + "Expect response.statusText is " + responseInit["statusText"]); + assert_equals(clonedResponse.headers.get("name"), "value", + "Expect response.headers has name:value header"); +}, "Check Response's clone has the expected attribute values"); + +promise_test(function(test) { + return validateStreamFromString(response.body.getReader(), body); +}, "Check orginal response's body after cloning"); + +promise_test(function(test) { + return validateStreamFromString(clonedResponse.body.getReader(), body); +}, "Check cloned response's body"); + +promise_test(function(test) { + var disturbedResponse = new Response("data"); + return disturbedResponse.text().then(function() { + assert_true(disturbedResponse.bodyUsed, "response is disturbed"); + assert_throws_js(TypeError, function() { disturbedResponse.clone(); }, + "Expect TypeError exception"); + }); +}, "Cannot clone a disturbed response"); + +promise_test(function(t) { + var clone; + var result; + var response; + return fetch('../resources/trickle.py?count=2&delay=100').then(function(res) { + clone = res.clone(); + response = res; + return clone.text(); + }).then(function(r) { + assert_equals(r.length, 26); + result = r; + return response.text(); + }).then(function(r) { + assert_equals(r, result, "cloned responses should provide the same data"); + }); + }, 'Cloned responses should provide the same data'); + +promise_test(function(t) { + var clone; + return fetch('../resources/trickle.py?count=2&delay=100').then(function(res) { + clone = res.clone(); + res.body.cancel(); + assert_true(res.bodyUsed); + assert_false(clone.bodyUsed); + return clone.arrayBuffer(); + }).then(function(r) { + assert_equals(r.byteLength, 26); + assert_true(clone.bodyUsed); + }); +}, 'Cancelling stream should not affect cloned one'); + +function testReadableStreamClone(initialBuffer, bufferType) +{ + promise_test(function(test) { + var response = new Response(new ReadableStream({start : function(controller) { + controller.enqueue(initialBuffer); + controller.close(); + }})); + + var clone = response.clone(); + var stream1 = response.body; + var stream2 = clone.body; + + var buffer; + return stream1.getReader().read().then(function(data) { + assert_false(data.done); + assert_equals(data.value, initialBuffer, "Buffer of being-cloned response stream is the same as the original buffer"); + return stream2.getReader().read(); + }).then(function(data) { + assert_false(data.done); + if (initialBuffer instanceof ArrayBuffer) { + assert_true(data.value instanceof ArrayBuffer, "Cloned buffer is ArrayBufer"); + assert_equals(initialBuffer.byteLength, data.value.byteLength, "Length equal"); + assert_array_equals(new Uint8Array(data.value), new Uint8Array(initialBuffer), "Cloned buffer chunks have the same content"); + } else if (initialBuffer instanceof DataView) { + assert_true(data.value instanceof DataView, "Cloned buffer is DataView"); + assert_equals(initialBuffer.byteLength, data.value.byteLength, "Lengths equal"); + assert_equals(initialBuffer.byteOffset, data.value.byteOffset, "Offsets equal"); + for (let i = 0; i < initialBuffer.byteLength; ++i) { + assert_equals( + data.value.getUint8(i), initialBuffer.getUint8(i), "Mismatch at byte ${i}"); + } + } else { + assert_array_equals(data.value, initialBuffer, "Cloned buffer chunks have the same content"); + } + assert_equals(Object.getPrototypeOf(data.value), Object.getPrototypeOf(initialBuffer), "Cloned buffers have the same type"); + assert_not_equals(data.value, initialBuffer, "Buffer of cloned response stream is a clone of the original buffer"); + }); + }, "Check response clone use structureClone for teed ReadableStreams (" + bufferType + "chunk)"); +} + +var arrayBuffer = new ArrayBuffer(16); +testReadableStreamClone(new Int8Array(arrayBuffer, 1), "Int8Array"); +testReadableStreamClone(new Int16Array(arrayBuffer, 2, 2), "Int16Array"); +testReadableStreamClone(new Int32Array(arrayBuffer), "Int32Array"); +testReadableStreamClone(arrayBuffer, "ArrayBuffer"); +testReadableStreamClone(new Uint8Array(arrayBuffer), "Uint8Array"); +testReadableStreamClone(new Uint8ClampedArray(arrayBuffer), "Uint8ClampedArray"); +testReadableStreamClone(new Uint16Array(arrayBuffer, 2), "Uint16Array"); +testReadableStreamClone(new Uint32Array(arrayBuffer), "Uint32Array"); +testReadableStreamClone(typeof BigInt64Array === "function" ? new BigInt64Array(arrayBuffer) : undefined, "BigInt64Array"); +testReadableStreamClone(typeof BigUint64Array === "function" ? new BigUint64Array(arrayBuffer) : undefined, "BigUint64Array"); +testReadableStreamClone(new Float32Array(arrayBuffer), "Float32Array"); +testReadableStreamClone(new Float64Array(arrayBuffer), "Float64Array"); +testReadableStreamClone(new DataView(arrayBuffer, 2, 8), "DataView"); diff --git a/test/wpt/tests/fetch/api/response/response-consume-empty.any.js b/test/wpt/tests/fetch/api/response/response-consume-empty.any.js new file mode 100644 index 0000000..0fa85ec --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-consume-empty.any.js @@ -0,0 +1,99 @@ +// META: global=window,worker +// META: title=Response consume empty bodies + +function checkBodyText(test, response) { + return response.text().then(function(bodyAsText) { + assert_equals(bodyAsText, "", "Resolved value should be empty"); + assert_false(response.bodyUsed); + }); +} + +function checkBodyBlob(test, response) { + return response.blob().then(function(bodyAsBlob) { + var promise = new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function(evt) { + resolve(reader.result) + }; + reader.onerror = function() { + reject("Blob's reader failed"); + }; + reader.readAsText(bodyAsBlob); + }); + return promise.then(function(body) { + assert_equals(body, "", "Resolved value should be empty"); + assert_false(response.bodyUsed); + }); + }); +} + +function checkBodyArrayBuffer(test, response) { + return response.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_false(response.bodyUsed); + }); +} + +function checkBodyJSON(test, response) { + return response.json().then( + function(bodyAsJSON) { + assert_unreached("JSON parsing should fail"); + }, + function() { + assert_false(response.bodyUsed); + }); +} + +function checkBodyFormData(test, response) { + return response.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_false(response.bodyUsed); + }); +} + +function checkBodyFormDataError(test, response) { + return promise_rejects_js(test, TypeError, response.formData()).then(function() { + assert_false(response.bodyUsed); + }); +} + +function checkResponseWithNoBody(bodyType, checkFunction, headers = []) { + promise_test(function(test) { + var response = new Response(undefined, { "headers": headers }); + assert_false(response.bodyUsed); + return checkFunction(test, response); + }, "Consume response's body as " + bodyType); +} + +checkResponseWithNoBody("text", checkBodyText); +checkResponseWithNoBody("blob", checkBodyBlob); +checkResponseWithNoBody("arrayBuffer", checkBodyArrayBuffer); +checkResponseWithNoBody("json (error case)", checkBodyJSON); +checkResponseWithNoBody("formData with correct multipart type (error case)", checkBodyFormDataError, [["Content-Type", 'multipart/form-data; boundary="boundary"']]); +checkResponseWithNoBody("formData with correct urlencoded type", checkBodyFormData, [["Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"]]); +checkResponseWithNoBody("formData without correct type (error case)", checkBodyFormDataError); + +function checkResponseWithEmptyBody(bodyType, body, asText) { + promise_test(function(test) { + var response = new Response(body); + assert_false(response.bodyUsed, "bodyUsed is false at init"); + if (asText) { + return response.text().then(function(bodyAsString) { + assert_equals(bodyAsString.length, 0, "Resolved value should be empty"); + assert_true(response.bodyUsed, "bodyUsed is true after being consumed"); + }); + } + return response.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_true(response.bodyUsed, "bodyUsed is true after being consumed"); + }); + }, "Consume empty " + bodyType + " response body as " + (asText ? "text" : "arrayBuffer")); +} + +checkResponseWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), false); +checkResponseWithEmptyBody("text", "", false); +checkResponseWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), true); +checkResponseWithEmptyBody("text", "", true); +checkResponseWithEmptyBody("URLSearchParams", new URLSearchParams(""), true); +checkResponseWithEmptyBody("FormData", new FormData(), true); +checkResponseWithEmptyBody("ArrayBuffer", new ArrayBuffer(), true); diff --git a/test/wpt/tests/fetch/api/response/response-consume-stream.any.js b/test/wpt/tests/fetch/api/response/response-consume-stream.any.js new file mode 100644 index 0000000..befce62 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-consume-stream.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Response consume +// META: script=../resources/utils.js + +promise_test(function(test) { + var body = ""; + var response = new Response(""); + return validateStreamFromString(response.body.getReader(), ""); +}, "Read empty text response's body as readableStream"); + +promise_test(function(test) { + var response = new Response(new Blob([], { "type" : "text/plain" })); + return validateStreamFromString(response.body.getReader(), ""); +}, "Read empty blob response's body as readableStream"); + +var formData = new FormData(); +formData.append("name", "value"); +var textData = JSON.stringify("This is response's body"); +var blob = new Blob([textData], { "type" : "text/plain" }); +var urlSearchParamsData = "name=value"; +var urlSearchParams = new URLSearchParams(urlSearchParamsData); + +for (const mode of [undefined, "byob"]) { + promise_test(function(test) { + var response = new Response(blob); + return validateStreamFromString(response.body.getReader({ mode }), textData); + }, `Read blob response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(textData); + return validateStreamFromString(response.body.getReader({ mode }), textData); + }, `Read text response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(urlSearchParams); + return validateStreamFromString(response.body.getReader({ mode }), urlSearchParamsData); + }, `Read URLSearchParams response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var arrayBuffer = new ArrayBuffer(textData.length); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < textData.length; cptr++) + int8Array[cptr] = textData.charCodeAt(cptr); + + return validateStreamFromString(new Response(arrayBuffer).body.getReader({ mode }), textData); + }, `Read array buffer response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(formData); + return validateStreamFromPartialString(response.body.getReader({ mode }), + "Content-Disposition: form-data; name=\"name\"\r\n\r\nvalue"); + }, `Read form data response's body as readableStream with mode=${mode}`); +} + +test(function() { + assert_equals(Response.error().body, null); +}, "Getting an error Response stream"); + +test(function() { + assert_equals(Response.redirect("/").body, null); +}, "Getting a redirect Response stream"); diff --git a/test/wpt/tests/fetch/api/response/response-consume.html b/test/wpt/tests/fetch/api/response/response-consume.html new file mode 100644 index 0000000..89fc49f --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-consume.html @@ -0,0 +1,317 @@ + + + + + Response consume + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/api/response/response-error-from-stream.any.js b/test/wpt/tests/fetch/api/response/response-error-from-stream.any.js new file mode 100644 index 0000000..118eb7d --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-error-from-stream.any.js @@ -0,0 +1,59 @@ +// META: global=window,worker +// META: title=Response Receives Propagated Error from ReadableStream + +function newStreamWithStartError() { + var err = new Error("Start error"); + return [new ReadableStream({ + start(controller) { + controller.error(err); + } + }), + err] +} + +function newStreamWithPullError() { + var err = new Error("Pull error"); + return [new ReadableStream({ + pull(controller) { + controller.error(err); + } + }), + err] +} + +function runRequestPromiseTest([stream, err], responseReaderMethod, testDescription) { + promise_test(test => { + return promise_rejects_exactly( + test, + err, + new Response(stream)[responseReaderMethod](), + 'CustomTestError should propagate' + ) + }, testDescription) +} + + +promise_test(test => { + var [stream, err] = newStreamWithStartError(); + return promise_rejects_exactly(test, err, stream.getReader().read(), 'CustomTestError should propagate') +}, "ReadableStreamDefaultReader Promise receives ReadableStream start() Error") + +promise_test(test => { + var [stream, err] = newStreamWithPullError(); + return promise_rejects_exactly(test, err, stream.getReader().read(), 'CustomTestError should propagate') +}, "ReadableStreamDefaultReader Promise receives ReadableStream pull() Error") + + +// test start() errors for all Body reader methods +runRequestPromiseTest(newStreamWithStartError(), 'arrayBuffer', 'ReadableStream start() Error propagates to Response.arrayBuffer() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'blob', 'ReadableStream start() Error propagates to Response.blob() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'formData', 'ReadableStream start() Error propagates to Response.formData() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'json', 'ReadableStream start() Error propagates to Response.json() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'text', 'ReadableStream start() Error propagates to Response.text() Promise'); + +// test pull() errors for all Body reader methods +runRequestPromiseTest(newStreamWithPullError(), 'arrayBuffer', 'ReadableStream pull() Error propagates to Response.arrayBuffer() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'blob', 'ReadableStream pull() Error propagates to Response.blob() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'formData', 'ReadableStream pull() Error propagates to Response.formData() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'json', 'ReadableStream pull() Error propagates to Response.json() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'text', 'ReadableStream pull() Error propagates to Response.text() Promise'); diff --git a/test/wpt/tests/fetch/api/response/response-error.any.js b/test/wpt/tests/fetch/api/response/response-error.any.js new file mode 100644 index 0000000..a76bc43 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-error.any.js @@ -0,0 +1,27 @@ +// META: global=window,worker +// META: title=Response error + +var invalidStatus = [0, 100, 199, 600, 1000]; +invalidStatus.forEach(function(status) { + test(function() { + assert_throws_js(RangeError, function() { new Response("", { "status" : status }); }, + "Expect RangeError exception when status is " + status); + },"Throws RangeError when responseInit's status is " + status); +}); + +var invalidStatusText = ["\n", "Ā"]; +invalidStatusText.forEach(function(statusText) { + test(function() { + assert_throws_js(TypeError, function() { new Response("", { "statusText" : statusText }); }, + "Expect TypeError exception " + statusText); + },"Throws TypeError when responseInit's statusText is " + statusText); +}); + +var nullBodyStatus = [204, 205, 304]; +nullBodyStatus.forEach(function(status) { + test(function() { + assert_throws_js(TypeError, + function() { new Response("body", {"status" : status }); }, + "Expect TypeError exception "); + },"Throws TypeError when building a response with body and a body status of " + status); +}); diff --git a/test/wpt/tests/fetch/api/response/response-from-stream.any.js b/test/wpt/tests/fetch/api/response/response-from-stream.any.js new file mode 100644 index 0000000..ea5192b --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-from-stream.any.js @@ -0,0 +1,23 @@ +// META: global=window,worker + +"use strict"; + +test(() => { + const stream = new ReadableStream(); + stream.getReader(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which getReader() is called"); + +test(() => { + const stream = new ReadableStream(); + stream.getReader().read(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which read() is called"); + +promise_test(async () => { + const stream = new ReadableStream({ pull: c => c.enqueue(new Uint8Array()) }), + reader = stream.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which read() and releaseLock() are called"); diff --git a/test/wpt/tests/fetch/api/response/response-init-001.any.js b/test/wpt/tests/fetch/api/response/response-init-001.any.js new file mode 100644 index 0000000..559e49a --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-init-001.any.js @@ -0,0 +1,64 @@ +// META: global=window,worker +// META: title=Response init: simple cases + +var defaultValues = { "type" : "default", + "url" : "", + "ok" : true, + "status" : 200, + "statusText" : "", + "body" : null +}; + +var statusCodes = { "givenValues" : [200, 300, 400, 500, 599], + "expectedValues" : [200, 300, 400, 500, 599] +}; +var statusTexts = { "givenValues" : ["", "OK", "with space", String.fromCharCode(0x80)], + "expectedValues" : ["", "OK", "with space", String.fromCharCode(0x80)] +}; +var initValuesDict = { "status" : statusCodes, + "statusText" : statusTexts +}; + +function isOkStatus(status) { + return 200 <= status && 299 >= status; +} + +var response = new Response(); +for (var attributeName in defaultValues) { + test(function() { + var expectedValue = defaultValues[attributeName]; + assert_equals(response[attributeName], expectedValue, + "Expect default response." + attributeName + " is " + expectedValue); + }, "Check default value for " + attributeName + " attribute"); +} + +for (var attributeName in initValuesDict) { + test(function() { + var valuesToTest = initValuesDict[attributeName]; + for (var valueIdx in valuesToTest["givenValues"]) { + var givenValue = valuesToTest["givenValues"][valueIdx]; + var expectedValue = valuesToTest["expectedValues"][valueIdx]; + var responseInit = {}; + responseInit[attributeName] = givenValue; + var response = new Response("", responseInit); + assert_equals(response[attributeName], expectedValue, + "Expect response." + attributeName + " is " + expectedValue + + " when initialized with " + givenValue); + assert_equals(response.ok, isOkStatus(response.status), + "Expect response.ok is " + isOkStatus(response.status)); + } + }, "Check " + attributeName + " init values and associated getter"); +} + +test(function() { + const response1 = new Response(""); + assert_equals(response1.headers, response1.headers); + + const response2 = new Response("", {"headers": {"X-Foo": "bar"}}); + assert_equals(response2.headers, response2.headers); + const headers = response2.headers; + response2.headers.set("X-Foo", "quux"); + assert_equals(headers, response2.headers); + headers.set("X-Other-Header", "baz"); + assert_equals(headers, response2.headers); +}, "Test that Response.headers has the [SameObject] extended attribute"); diff --git a/test/wpt/tests/fetch/api/response/response-init-002.any.js b/test/wpt/tests/fetch/api/response/response-init-002.any.js new file mode 100644 index 0000000..6c0a46e --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-init-002.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Response init: body and headers +// META: script=../resources/utils.js + +test(function() { + var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3" + }; + var headers = new Headers(headerDict); + var response = new Response("", { "headers" : headers }) + for (var name in headerDict) { + assert_equals(response.headers.get(name), headerDict[name], + "response's headers has " + name + " : " + headerDict[name]); + } +}, "Initialize Response with headers values"); + +function checkResponseInit(body, bodyType, expectedTextBody) { + promise_test(function(test) { + var response = new Response(body); + var resHeaders = response.headers; + var mime = resHeaders.get("Content-Type"); + assert_true(mime && mime.search(bodyType) > -1, "Content-Type header should be \"" + bodyType + "\" "); + return response.text().then(function(bodyAsText) { + //not equals: cannot guess formData exact value + assert_true(bodyAsText.search(expectedTextBody) > -1, "Retrieve and verify response body"); + }); + }, "Initialize Response's body with " + bodyType); +} + +var blob = new Blob(["This is a blob"], {type: "application/octet-binary"}); +var formaData = new FormData(); +formaData.append("name", "value"); +var urlSearchParams = "URLSearchParams are not supported"; +//avoid test timeout if not implemented +if (self.URLSearchParams) + urlSearchParams = new URLSearchParams("name=value"); +var usvString = "This is a USVString" + +checkResponseInit(blob, "application/octet-binary", "This is a blob"); +checkResponseInit(formaData, "multipart/form-data", "name=\"name\"\r\n\r\nvalue"); +checkResponseInit(urlSearchParams, "application/x-www-form-urlencoded;charset=UTF-8", "name=value"); +checkResponseInit(usvString, "text/plain;charset=UTF-8", "This is a USVString"); + +promise_test(function(test) { + var body = "This is response body"; + var response = new Response(body); + return validateStreamFromString(response.body.getReader(), body); +}, "Read Response's body as readableStream"); + +promise_test(function(test) { + var response = new Response("This is my fork", {"headers" : [["Content-Type", ""]]}); + return response.blob().then(function(blob) { + assert_equals(blob.type, "", "Blob type should be the empty string"); + }); +}, "Testing empty Response Content-Type header"); + +test(function() { + var response = new Response(null, {status: 204}); + assert_equals(response.body, null); +}, "Testing null Response body"); diff --git a/test/wpt/tests/fetch/api/response/response-init-contenttype.any.js b/test/wpt/tests/fetch/api/response/response-init-contenttype.any.js new file mode 100644 index 0000000..3a7744c --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-init-contenttype.any.js @@ -0,0 +1,125 @@ +test(() => { + const response = new Response(); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with empty body"); + +test(() => { + const blob = new Blob([]); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), "a/b; c=d"); +}, "Default Content-Type for Response with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const response = new Response(buffer); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with buffer source body"); + +promise_test(async () => { + const formData = new FormData(); + formData.append("a", "b"); + const response = new Response(formData); + const boundary = (await response.text()).split("\r\n")[0].slice(2); + assert_equals( + response.headers.get("Content-Type"), + `multipart/form-data; boundary=${boundary}`, + ); +}, "Default Content-Type for Response with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const response = new Response(usp); + assert_equals( + response.headers.get("Content-Type"), + "application/x-www-form-urlencoded;charset=UTF-8", + ); +}, "Default Content-Type for Response with URLSearchParams body"); + +test(() => { + const response = new Response(""); + assert_equals( + response.headers.get("Content-Type"), + "text/plain;charset=UTF-8", + ); +}, "Default Content-Type for Response with string body"); + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with ReadableStream body"); + +// ----------------------------------------------------------------------------- + +const OVERRIDE_MIME = "test/only; mime=type"; + +function responseWithOverrideMime(body) { + return new Response( + body, + { headers: { "Content-Type": OVERRIDE_MIME } }, + ); +} + +test(() => { + const response = responseWithOverrideMime(undefined); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with empty body"); + +test(() => { + const blob = new Blob([]); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const response = responseWithOverrideMime(buffer); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with buffer source body"); + +test(() => { + const formData = new FormData(); + const response = responseWithOverrideMime(formData); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const response = responseWithOverrideMime(usp); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with URLSearchParams body"); + +test(() => { + const response = responseWithOverrideMime(""); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with string body"); + +test(() => { + const stream = new ReadableStream(); + const response = responseWithOverrideMime(stream); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with ReadableStream body"); diff --git a/test/wpt/tests/fetch/api/response/response-static-error.any.js b/test/wpt/tests/fetch/api/response/response-static-error.any.js new file mode 100644 index 0000000..1f8c49a --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-static-error.any.js @@ -0,0 +1,34 @@ +// META: global=window,worker +// META: title=Response: error static method + +test(function() { + var responseError = Response.error(); + assert_equals(responseError.type, "error", "Network error response's type is error"); + assert_equals(responseError.status, 0, "Network error response's status is 0"); + assert_equals(responseError.statusText, "", "Network error response's statusText is empty"); + assert_equals(responseError.body, null, "Network error response's body is null"); + + assert_true(responseError.headers.entries().next().done, "Headers should be empty"); +}, "Check response returned by static method error()"); + +promise_test (async function() { + let response = await fetch("../resources/data.json"); + + try { + response.headers.append('name', 'value'); + } catch (e) { + assert_equals(e.constructor.name, "TypeError"); + } + + assert_not_equals(response.headers.get("name"), "value", "response headers should be immutable"); +}, "Ensure response headers are immutable"); + +test(function() { + const headers = Response.error().headers; + + // Avoid false positives if expected API is not available + assert_true(!!headers); + assert_equals(typeof headers.append, 'function'); + + assert_throws_js(TypeError, function () { headers.append('name', 'value'); }); +}, "the 'guard' of the Headers instance should be immutable"); diff --git a/test/wpt/tests/fetch/api/response/response-static-json.any.js b/test/wpt/tests/fetch/api/response/response-static-json.any.js new file mode 100644 index 0000000..5ec79e6 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-static-json.any.js @@ -0,0 +1,96 @@ +// META: global=window,worker +// META: title=Response: json static method + +const APPLICATION_JSON = "application/json"; +const FOO_BAR = "foo/bar"; + +const INIT_TESTS = [ + [undefined, 200, "", APPLICATION_JSON, {}], + [{ status: 400 }, 400, "", APPLICATION_JSON, {}], + [{ statusText: "foo" }, 200, "foo", APPLICATION_JSON, {}], + [{ headers: {} }, 200, "", APPLICATION_JSON, {}], + [{ headers: { "content-type": FOO_BAR } }, 200, "", FOO_BAR, {}], + [{ headers: { "x-foo": "bar" } }, 200, "", APPLICATION_JSON, { "x-foo": "bar" }], +]; + +for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) { + promise_test(async function () { + const response = Response.json("hello world", init); + assert_equals(response.type, "default", "Response's type is default"); + assert_equals(response.status, expectedStatus, "Response's status is " + expectedStatus); + assert_equals(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText)); + assert_equals(response.headers.get("content-type"), expectedContentType, "Response's content-type is " + expectedContentType); + for (const key in expectedHeaders) { + assert_equals(response.headers.get(key), expectedHeaders[key], "Response's header " + key + " is " + JSON.stringify(expectedHeaders[key])); + } + + const data = await response.json(); + assert_equals(data, "hello world", "Response's body is 'hello world'"); + }, `Check response returned by static json() with init ${JSON.stringify(init)}`); +} + +const nullBodyStatus = [204, 205, 304]; +for (const status of nullBodyStatus) { + test(function () { + assert_throws_js( + TypeError, + function () { + Response.json("hello world", { status: status }); + }, + ); + }, `Throws TypeError when calling static json() with a status of ${status}`); +} + +promise_test(async function () { + const response = Response.json({ foo: "bar" }); + const data = await response.json(); + assert_equals(typeof data, "object", "Response's json body is an object"); + assert_equals(data.foo, "bar", "Response's json body is { foo: 'bar' }"); +}, "Check static json() encodes JSON objects correctly"); + +test(function () { + assert_throws_js( + TypeError, + function () { + Response.json(Symbol("foo")); + }, + ); +}, "Check static json() throws when data is not encodable"); + +test(function () { + const a = { b: 1 }; + a.a = a; + assert_throws_js( + TypeError, + function () { + Response.json(a); + }, + ); +}, "Check static json() throws when data is circular"); + +promise_test(async function () { + class CustomError extends Error { + name = "CustomError"; + } + assert_throws_js( + CustomError, + function () { + Response.json({ get foo() { throw new CustomError("bar") }}); + } + ) +}, "Check static json() propagates JSON serializer errors"); + +const encodingChecks = [ + ["𝌆", [34, 240, 157, 140, 134, 34]], + ["\uDF06\uD834", [34, 92, 117, 100, 102, 48, 54, 92, 117, 100, 56, 51, 52, 34]], + ["\uDEAD", [34, 92, 117, 100, 101, 97, 100, 34]], +]; + +for (const [input, expected] of encodingChecks) { + promise_test(async function () { + const response = Response.json(input); + const buffer = await response.arrayBuffer(); + const data = new Uint8Array(buffer); + assert_array_equals(data, expected); + }, `Check response returned by static json() with input ${input}`); +} diff --git a/test/wpt/tests/fetch/api/response/response-static-redirect.any.js b/test/wpt/tests/fetch/api/response/response-static-redirect.any.js new file mode 100644 index 0000000..b16c56d --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-static-redirect.any.js @@ -0,0 +1,40 @@ +// META: global=window,worker +// META: title=Response: redirect static method + +var url = "http://test.url:1234/"; +test(function() { + const redirectResponse = Response.redirect(url); + assert_equals(redirectResponse.type, "default"); + assert_false(redirectResponse.redirected); + assert_false(redirectResponse.ok); + assert_equals(redirectResponse.status, 302, "Default redirect status is 302"); + assert_equals(redirectResponse.headers.get("Location"), url, + "redirected response has Location header with the correct url"); + assert_equals(redirectResponse.statusText, ""); +}, "Check default redirect response"); + +[301, 302, 303, 307, 308].forEach(function(status) { + test(function() { + const redirectResponse = Response.redirect(url, status); + assert_equals(redirectResponse.type, "default"); + assert_false(redirectResponse.redirected); + assert_false(redirectResponse.ok); + assert_equals(redirectResponse.status, status, "Redirect status is " + status); + assert_equals(redirectResponse.headers.get("Location"), url); + assert_equals(redirectResponse.statusText, ""); + }, "Check response returned by static method redirect(), status = " + status); +}); + +test(function() { + var invalidUrl = "http://:This is not an url"; + assert_throws_js(TypeError, function() { Response.redirect(invalidUrl); }, + "Expect TypeError exception"); +}, "Check error returned when giving invalid url to redirect()"); + +var invalidRedirectStatus = [200, 309, 400, 500]; +invalidRedirectStatus.forEach(function(invalidStatus) { + test(function() { + assert_throws_js(RangeError, function() { Response.redirect(url, invalidStatus); }, + "Expect RangeError exception"); + }, "Check error returned when giving invalid status to redirect(), status = " + invalidStatus); +}); diff --git a/test/wpt/tests/fetch/api/response/response-stream-bad-chunk.any.js b/test/wpt/tests/fetch/api/response/response-stream-bad-chunk.any.js new file mode 100644 index 0000000..d3d92e1 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-bad-chunk.any.js @@ -0,0 +1,24 @@ +// META: global=window,worker +// META: title=Response causes TypeError from bad chunk type + +function runChunkTest(responseReaderMethod, testDescription) { + promise_test(test => { + let stream = new ReadableStream({ + start(controller) { + controller.enqueue("not Uint8Array"); + controller.close(); + } + }); + + return promise_rejects_js(test, TypeError, + new Response(stream)[responseReaderMethod](), + 'TypeError should propagate' + ) + }, testDescription) +} + +runChunkTest('arrayBuffer', 'ReadableStream with non-Uint8Array chunk passed to Response.arrayBuffer() causes TypeError'); +runChunkTest('blob', 'ReadableStream with non-Uint8Array chunk passed to Response.blob() causes TypeError'); +runChunkTest('formData', 'ReadableStream with non-Uint8Array chunk passed to Response.formData() causes TypeError'); +runChunkTest('json', 'ReadableStream with non-Uint8Array chunk passed to Response.json() causes TypeError'); +runChunkTest('text', 'ReadableStream with non-Uint8Array chunk passed to Response.text() causes TypeError'); diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-1.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-1.any.js new file mode 100644 index 0000000..64f65f1 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-1.any.js @@ -0,0 +1,44 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + const reader = response.body.getReader(); + reader.releaseLock(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.blob().then(function(blob) { + assert_true(blob instanceof Blob); + }); + }); + }, `Getting blob after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.text().then(function(text) { + assert_true(text.length > 0); + }); + }); + }, `Getting text after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.json().then(function(json) { + assert_equals(typeof json, "object"); + }); + }); + }, `Getting json after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.arrayBuffer().then(function(arrayBuffer) { + assert_true(arrayBuffer.byteLength > 0); + }); + }); + }, `Getting arrayBuffer after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-2.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-2.any.js new file mode 100644 index 0000000..c46a180 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-2.any.js @@ -0,0 +1,35 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithLockedReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + response.body.getReader(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after getting a locked Response body (body source: ${bodySource})`); +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-3.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-3.any.js new file mode 100644 index 0000000..35fb086 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-3.any.js @@ -0,0 +1,36 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithDisturbedReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + const reader = response.body.getReader(); + reader.read(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after reading the Response body (body source: ${bodySource})`); +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-4.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-4.any.js new file mode 100644 index 0000000..490672f --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-4.any.js @@ -0,0 +1,35 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithCancelledReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + response.body.cancel(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after cancelling the Response body (body source: ${bodySource})`); +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-5.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-5.any.js new file mode 100644 index 0000000..348fc39 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-5.any.js @@ -0,0 +1,19 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +for (const bodySource of ["fetch", "stream", "string"]) { + for (const consumeAs of ["blob", "text", "json", "arrayBuffer"]) { + promise_test( + async () => { + const response = await responseFromBodySource(bodySource); + response[consumeAs](); + assert_not_equals(response.body, null); + assert_throws_js(TypeError, function () { + response.body.getReader(); + }); + }, + `Getting a body reader after consuming as ${consumeAs} (body source: ${bodySource})`, + ); + } +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-6.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-6.any.js new file mode 100644 index 0000000..61d8544 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-6.any.js @@ -0,0 +1,76 @@ +// META: global=window,worker +// META: title=ReadableStream disturbed tests, via Response's bodyUsed property + +"use strict"; + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read(); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "A non-closed stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.cancel(); + assert_true(response.bodyUsed, "After calling stream.cancel()"); +}, "A non-closed stream on which cancel() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.close(); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read(); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "A closed stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.error(new Error("some error")); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read().then(() => { }, () => { }); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "An errored stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.error(new Error("some error")); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.cancel().then(() => { }, () => { }); + assert_true(response.bodyUsed, "After calling stream.cancel()"); +}, "An errored stream on which cancel() has been called"); diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-by-pipe.any.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-by-pipe.any.js new file mode 100644 index 0000000..5341b75 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-by-pipe.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +test(() => { + const r = new Response(new ReadableStream()); + // highWaterMark: 0 means that nothing will actually be read from the body. + r.body.pipeTo(new WritableStream({}, {highWaterMark: 0})); + assert_true(r.bodyUsed, 'bodyUsed should be true'); +}, 'using pipeTo on Response body should disturb it synchronously'); + +test(() => { + const r = new Response(new ReadableStream()); + r.body.pipeThrough({ + writable: new WritableStream({}, {highWaterMark: 0}), + readable: new ReadableStream() + }); + assert_true(r.bodyUsed, 'bodyUsed should be true'); +}, 'using pipeThrough on Response body should disturb it synchronously'); diff --git a/test/wpt/tests/fetch/api/response/response-stream-disturbed-util.js b/test/wpt/tests/fetch/api/response/response-stream-disturbed-util.js new file mode 100644 index 0000000..50bb586 --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-disturbed-util.js @@ -0,0 +1,17 @@ +const BODY = '{"key": "value"}'; + +function responseFromBodySource(bodySource) { + if (bodySource === "fetch") { + return fetch("../resources/data.json"); + } else if (bodySource === "stream") { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(BODY)); + controller.close(); + }, + }); + return new Response(stream); + } else { + return new Response(BODY); + } +} diff --git a/test/wpt/tests/fetch/api/response/response-stream-with-broken-then.any.js b/test/wpt/tests/fetch/api/response/response-stream-with-broken-then.any.js new file mode 100644 index 0000000..8fef66c --- /dev/null +++ b/test/wpt/tests/fetch/api/response/response-stream-with-broken-then.any.js @@ -0,0 +1,117 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +promise_test(async () => { + // t.add_cleanup doesn't work when Object.prototype.then is overwritten, so + // these tests use add_completion_callback for cleanup instead. + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const bye = new TextEncoder().encode('bye'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: bye}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject {done: false, value: bye} via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: undefined}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject value: undefined via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled(undefined); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject undefined via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled(8.2); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject 8.2 via Object.prototype.then.'); + +promise_test(async () => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const bye = new TextEncoder().encode('bye'); + const resp = new Response(hello); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: bye}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'intercepting arraybuffer to text conversion via Object.prototype.then ' + + 'should not be possible'); + +promise_test(async () => { + add_completion_callback(() => delete Object.prototype.then); + const u8a123 = new Uint8Array([1, 2, 3]); + const u8a456 = new Uint8Array([4, 5, 6]); + const resp = new Response(u8a123); + const writtenBytes = []; + const ws = new WritableStream({ + write(chunk) { + writtenBytes.push(...Array.from(chunk)); + } + }); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: u8a456}); + }; + await resp.body.pipeTo(ws); + delete Object.prototype.then; + assert_array_equals(writtenBytes, u8a123, 'The value should be [1, 2, 3]'); +}, 'intercepting arraybuffer to body readable stream conversion via ' + + 'Object.prototype.then should not be possible'); diff --git a/test/wpt/tests/fetch/connection-pool/network-partition-key.html b/test/wpt/tests/fetch/connection-pool/network-partition-key.html new file mode 100644 index 0000000..60a784c --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/network-partition-key.html @@ -0,0 +1,264 @@ + + + + + Connection partitioning by site + + + + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-about-blank-checker.html b/test/wpt/tests/fetch/connection-pool/resources/network-partition-about-blank-checker.html new file mode 100644 index 0000000..7a8b613 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-about-blank-checker.html @@ -0,0 +1,35 @@ + + + + + about:blank Network Partition Checker + + + + + + + diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-checker.html b/test/wpt/tests/fetch/connection-pool/resources/network-partition-checker.html new file mode 100644 index 0000000..b058f61 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-checker.html @@ -0,0 +1,30 @@ + + + + + Network Partition Checker + + + + + + + + + + diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-iframe-checker.html b/test/wpt/tests/fetch/connection-pool/resources/network-partition-iframe-checker.html new file mode 100644 index 0000000..f76ed18 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-iframe-checker.html @@ -0,0 +1,22 @@ + + + + + Iframe Network Partition Checker + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.js b/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.js new file mode 100644 index 0000000..bd66109 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.js @@ -0,0 +1,47 @@ +// Runs multiple fetches that validate connections see only a single partition_id. +// Requests are run in parallel so that they use multiple connections to maximize the +// chance of exercising all matching connections in the connection pool. Only returns +// once all requests have completed to make cleaning up server state non-racy. +function check_partition_ids(location) { + const NUM_FETCHES = 20; + + var base_url = 'SUBRESOURCE_PREFIX:&dispatch=check_partition'; + + // Not a perfect parse of the query string, but good enough for this test. + var include_credentials = base_url.search('include_credentials=true') != -1; + var exclude_credentials = base_url.search('include_credentials=false') != -1; + if (include_credentials != !exclude_credentials) + throw new Exception('Credentials mode not specified'); + + + // Run NUM_FETCHES in parallel. + var fetches = []; + for (i = 0; i < NUM_FETCHES; ++i) { + var fetch_params = { + credentials: 'omit', + mode: 'cors', + headers: { + 'Header-To-Force-CORS': 'cors' + }, + }; + + // Use a unique URL for each request, in case the caching layer serializes multiple + // requests for the same URL. + var url = `${base_url}&${token()}`; + + fetches.push(fetch(url, fetch_params).then( + function (response) { + return response.text().then(function(text) { + assert_equals(text, 'ok', `Socket unexpectedly reused`); + }); + })); + } + + // Wait for all promises to complete. + return Promise.allSettled(fetches).then(function (results) { + results.forEach(function (result) { + if (result.status != 'fulfilled') + throw result.reason; + }); + }); +} diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.py b/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.py new file mode 100644 index 0000000..32fe499 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-key.py @@ -0,0 +1,130 @@ +import mimetypes +import os + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +# Test server that tracks the last partition_id was used with each connection for each uuid, and +# lets consumers query if multiple different partition_ids have been been used for any socket. +# +# Server assumes that ports aren't reused, so a client address and a server port uniquely identify +# a connection. If that constraint is ever violated, the test will be flaky. No sockets being +# closed for the duration of the test is sufficient to ensure that, though even if sockets are +# closed, the OS should generally prefer to use new ports for new connections, if any are +# available. +def main(request, response): + response.headers.set(b"Cache-Control", b"no-store") + dispatch = request.GET.first(b"dispatch", None) + uuid = request.GET.first(b"uuid", None) + partition_id = request.GET.first(b"partition_id", None) + + if not uuid or not dispatch or not partition_id: + return simple_response(request, response, 404, b"Not found", b"Invalid query parameters") + + # Unless nocheck_partition is true, check partition_id against server_state, and update server_state. + stash = request.server.stash + test_failed = False + request_count = 0; + connection_count = 0; + if request.GET.first(b"nocheck_partition", None) != b"True": + # Need to grab the lock to access the Stash, since requests are made in parallel. + with stash.lock: + # Don't use server hostname here, since H2 allows multiple hosts to reuse a connection. + # Server IP is not currently available, unfortunately. + address_key = isomorphic_encode(str(request.client_address) + u"|" + str(request.url_parts.port)) + server_state = stash.take(uuid) or {b"test_failed": False, + b"request_count": 0, b"connection_count": 0} + request_count = server_state[b"request_count"] + request_count += 1 + server_state[b"request_count"] = request_count + if address_key in server_state: + if server_state[address_key] != partition_id: + server_state[b"test_failed"] = True + else: + connection_count = server_state[b"connection_count"] + connection_count += 1 + server_state[b"connection_count"] = connection_count + server_state[address_key] = partition_id + test_failed = server_state[b"test_failed"] + stash.put(uuid, server_state) + + origin = request.headers.get(b"Origin") + if origin: + response.headers.set(b"Access-Control-Allow-Origin", origin) + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + + if request.method == u"OPTIONS": + return handle_preflight(request, response) + + if dispatch == b"fetch_file": + return handle_fetch_file(request, response, partition_id, uuid) + + if dispatch == b"check_partition": + status = request.GET.first(b"status", 200) + if test_failed: + return simple_response(request, response, status, b"OK", b"Multiple partition IDs used on a socket") + body = b"ok" + if request.GET.first(b"addcounter", False): + body += (". Request was sent " + str(request_count) + " times. " + + str(connection_count) + " connections were created.").encode('utf-8') + return simple_response(request, response, status, b"OK", body) + + if dispatch == b"clean_up": + stash.take(uuid) + if test_failed: + return simple_response(request, response, 200, b"OK", b"Test failed, but cleanup completed.") + return simple_response(request, response, 200, b"OK", b"cleanup complete") + + return simple_response(request, response, 404, b"Not Found", b"Unrecognized dispatch parameter: " + dispatch) + +def handle_preflight(request, response): + response.status = (200, b"OK") + response.headers.set(b"Access-Control-Allow-Methods", b"GET") + response.headers.set(b"Access-Control-Allow-Headers", b"header-to-force-cors") + response.headers.set(b"Access-Control-Max-Age", b"86400") + return b"Preflight request" + +def simple_response(request, response, status_code, status_message, body, content_type=b"text/plain"): + response.status = (status_code, status_message) + response.headers.set(b"Content-Type", content_type) + return body + +def handle_fetch_file(request, response, partition_id, uuid): + subresource_origin = request.GET.first(b"subresource_origin", None) + rel_path = request.GET.first(b"path", None) + + # This needs to be passed on to subresources so they all have access to it. + include_credentials = request.GET.first(b"include_credentials", None) + if not subresource_origin or not rel_path or not include_credentials: + return simple_response(request, response, 404, b"Not found", b"Invalid query parameters") + + cur_path = os.path.realpath(isomorphic_decode(__file__)) + base_path = os.path.abspath(os.path.join(os.path.dirname(cur_path), os.pardir, os.pardir, os.pardir)) + path = os.path.abspath(os.path.join(base_path, isomorphic_decode(rel_path))) + + # Basic security check. + if not path.startswith(base_path): + return simple_response(request, response, 404, b"Not found", b"Invalid path") + + sandbox = request.GET.first(b"sandbox", None) + if sandbox == b"true": + response.headers.set(b"Content-Security-Policy", b"sandbox allow-scripts") + + file = open(path, mode="rb") + body = file.read() + file.close() + + subresource_path = b"/" + isomorphic_encode(os.path.relpath(isomorphic_decode(__file__), base_path)).replace(b'\\', b'/') + subresource_params = b"?partition_id=" + partition_id + b"&uuid=" + uuid + b"&subresource_origin=" + subresource_origin + b"&include_credentials=" + include_credentials + body = body.replace(b"SUBRESOURCE_PREFIX:", subresource_origin + subresource_path + subresource_params) + + other_origin = request.GET.first(b"other_origin", None) + if other_origin: + body = body.replace(b"OTHER_PREFIX:", other_origin + subresource_path + subresource_params) + + mimetypes.init() + mimetype_pair = mimetypes.guess_type(path) + mimetype = mimetype_pair[0] + + if mimetype == None or mimetype_pair[1] != None: + return simple_response(request, response, 500, b"Server Error", b"Unknown MIME type") + return simple_response(request, response, 200, b"OK", body, mimetype) diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker-checker.html b/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker-checker.html new file mode 100644 index 0000000..e6b7ea7 --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker-checker.html @@ -0,0 +1,24 @@ + + + + + Worker Network Partition Checker + + + + + + + + + + diff --git a/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker.js b/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker.js new file mode 100644 index 0000000..1745edf --- /dev/null +++ b/test/wpt/tests/fetch/connection-pool/resources/network-partition-worker.js @@ -0,0 +1,15 @@ +// This tests the partition key of fetches to subresouce_origin made by the worker and +// imported scripts from subresource_origin. +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=common/utils.js'); +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=resources/testharness.js'); +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=fetch/connection-pool/resources/network-partition-key.js'); + +async function fetch_and_reply() { + try { + await check_partition_ids(); + self.postMessage({result: 'success'}); + } catch (e) { + self.postMessage({result: 'error', details: e.message}); + } +} +fetch_and_reply(); diff --git a/test/wpt/tests/fetch/content-encoding/bad-gzip-body.any.js b/test/wpt/tests/fetch/content-encoding/bad-gzip-body.any.js new file mode 100644 index 0000000..17bc126 --- /dev/null +++ b/test/wpt/tests/fetch/content-encoding/bad-gzip-body.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker + +promise_test((test) => { + return fetch("resources/bad-gzip-body.py").then(res => { + assert_equals(res.status, 200); + }); +}, "Fetching a resource with bad gzip content should still resolve"); + +[ + "arrayBuffer", + "blob", + "formData", + "json", + "text" +].forEach(method => { + promise_test(t => { + return fetch("resources/bad-gzip-body.py").then(res => { + assert_equals(res.status, 200); + return promise_rejects_js(t, TypeError, res[method]()); + }); + }, "Consuming the body of a resource with bad gzip content with " + method + "() should reject"); +}); diff --git a/test/wpt/tests/fetch/content-encoding/gzip-body.any.js b/test/wpt/tests/fetch/content-encoding/gzip-body.any.js new file mode 100644 index 0000000..37758b7 --- /dev/null +++ b/test/wpt/tests/fetch/content-encoding/gzip-body.any.js @@ -0,0 +1,16 @@ +// META: global=window,worker + +const expectedDecompressedSize = 10500; +[ + "text", + "octetstream" +].forEach(contentType => { + promise_test(async t => { + let response = await fetch(`resources/foo.${contentType}.gz`); + assert_true(response.ok); + let arrayBuffer = await response.arrayBuffer() + let u8 = new Uint8Array(arrayBuffer); + assert_equals(u8.length, expectedDecompressedSize); + }, `fetched gzip data with content type ${contentType} should be decompressed.`); +}); + diff --git a/test/wpt/tests/fetch/content-encoding/resources/bad-gzip-body.py b/test/wpt/tests/fetch/content-encoding/resources/bad-gzip-body.py new file mode 100644 index 0000000..a79b94e --- /dev/null +++ b/test/wpt/tests/fetch/content-encoding/resources/bad-gzip-body.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Content-Encoding", b"gzip")] + return headers, b"not actually gzip" diff --git a/test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz b/test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz new file mode 100644 index 0000000000000000000000000000000000000000..f3df4cb89b5d0b78bc76d0ce03ab0c205c5211fa GIT binary patch literal 64 zcmb2|=HO^#DM@BvPRq~N%TF#zEh#Q3N=?jVczb9gCj$cm(}Miy-}SHi91L?qLSf)O NUv!;XDT^ip0|3Ob6}$id literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz.headers b/test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz.headers new file mode 100644 index 0000000..27d4f40 --- /dev/null +++ b/test/wpt/tests/fetch/content-encoding/resources/foo.octetstream.gz.headers @@ -0,0 +1,2 @@ +Content-type: application/octet-stream +Content-Encoding: gzip diff --git a/test/wpt/tests/fetch/content-encoding/resources/foo.text.gz b/test/wpt/tests/fetch/content-encoding/resources/foo.text.gz new file mode 100644 index 0000000000000000000000000000000000000000..05a5cce07b514365d9c468f4a1763b8173cfecfc GIT binary patch literal 57 zcmb2|=HRGgDM@BvPRq~ND@m;=VR(CJBPRm`1Ji>1=->6P`y32&LqcKTK3{a5S}BVr G0|NjdXcEo< literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/content-encoding/resources/foo.text.gz.headers b/test/wpt/tests/fetch/content-encoding/resources/foo.text.gz.headers new file mode 100644 index 0000000..7def3dd --- /dev/null +++ b/test/wpt/tests/fetch/content-encoding/resources/foo.text.gz.headers @@ -0,0 +1,2 @@ +Content-type: text/plain +Content-Encoding: gzip diff --git a/test/wpt/tests/fetch/content-length/api-and-duplicate-headers.any.js b/test/wpt/tests/fetch/content-length/api-and-duplicate-headers.any.js new file mode 100644 index 0000000..8015289 --- /dev/null +++ b/test/wpt/tests/fetch/content-length/api-and-duplicate-headers.any.js @@ -0,0 +1,23 @@ +promise_test(async t => { + const response = await fetch("resources/identical-duplicates.asis"); + assert_equals(response.statusText, "BLAH"); + assert_equals(response.headers.get("test"), "x, x"); + assert_equals(response.headers.get("content-type"), "text/plain, text/plain"); + assert_equals(response.headers.get("content-length"), "6, 6"); + const text = await response.text(); + assert_equals(text, "Test.\n"); +}, "fetch() and duplicate Content-Length/Content-Type headers"); + +async_test(t => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", "resources/identical-duplicates.asis"); + xhr.send(); + xhr.onload = t.step_func_done(() => { + assert_equals(xhr.statusText, "BLAH"); + assert_equals(xhr.getResponseHeader("test"), "x, x"); + assert_equals(xhr.getResponseHeader("content-type"), "text/plain, text/plain"); + assert_equals(xhr.getResponseHeader("content-length"), "6, 6"); + assert_equals(xhr.getAllResponseHeaders(), "content-length: 6, 6\r\ncontent-type: text/plain, text/plain\r\ntest: x, x\r\n"); + assert_equals(xhr.responseText, "Test.\n"); + }); +}, "XMLHttpRequest and duplicate Content-Length/Content-Type headers"); diff --git a/test/wpt/tests/fetch/content-length/content-length.html b/test/wpt/tests/fetch/content-length/content-length.html new file mode 100644 index 0000000..cda9b5b --- /dev/null +++ b/test/wpt/tests/fetch/content-length/content-length.html @@ -0,0 +1,14 @@ + + +Content-Length Test + + + +PASS +but FAIL if this is in the body. \ No newline at end of file diff --git a/test/wpt/tests/fetch/content-length/content-length.html.headers b/test/wpt/tests/fetch/content-length/content-length.html.headers new file mode 100644 index 0000000..25389b7 --- /dev/null +++ b/test/wpt/tests/fetch/content-length/content-length.html.headers @@ -0,0 +1 @@ +Content-Length: 403 diff --git a/test/wpt/tests/fetch/content-length/parsing.window.js b/test/wpt/tests/fetch/content-length/parsing.window.js new file mode 100644 index 0000000..5028ad9 --- /dev/null +++ b/test/wpt/tests/fetch/content-length/parsing.window.js @@ -0,0 +1,18 @@ +promise_test(() => { + return fetch("resources/content-lengths.json").then(res => res.json()).then(runTests); +}, "Loading JSON…"); + +function runTests(testUnits) { + testUnits.forEach(({ input, output }) => { + promise_test(t => { + const result = fetch(`resources/content-length.py?length=${encodeURIComponent(input)}`); + if (output === null) { + return promise_rejects_js(t, TypeError, result); + } else { + return result.then(res => res.text()).then(text => { + assert_equals(text.length, output); + }); + } + }, `Input: ${format_value(input)}. Expected: ${output === null ? "network error" : output}.`); + }); +} diff --git a/test/wpt/tests/fetch/content-length/resources/content-length.py b/test/wpt/tests/fetch/content-length/resources/content-length.py new file mode 100644 index 0000000..92cfade --- /dev/null +++ b/test/wpt/tests/fetch/content-length/resources/content-length.py @@ -0,0 +1,10 @@ +def main(request, response): + response.add_required_headers = False + output = b"HTTP/1.1 200 OK\r\n" + output += b"Content-Type: text/plain;charset=UTF-8\r\n" + output += b"Connection: close\r\n" + output += request.GET.first(b"length") + b"\r\n" + output += b"\r\n" + output += b"Fact: this is really forty-two bytes long." + response.writer.write(output) + response.close_connection = True diff --git a/test/wpt/tests/fetch/content-length/resources/content-lengths.json b/test/wpt/tests/fetch/content-length/resources/content-lengths.json new file mode 100644 index 0000000..ac6f1a2 --- /dev/null +++ b/test/wpt/tests/fetch/content-length/resources/content-lengths.json @@ -0,0 +1,142 @@ +[ + { + "input": "Content-Length: 42", + "output": 42 + }, + { + "input": "Content-Length: 42,42", + "output": 42 + }, + { + "input": "Content-Length: 42\r\nContent-Length: 42", + "output": 42 + }, + { + "input": "Content-Length: 42\r\nContent-Length: 42,42", + "output": 42 + }, + { + "input": "Content-Length: 30", + "output": 30 + }, + { + "input": "Content-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30\r\nContent-Length: 30", + "output": 30 + }, + { + "input": "Content-Length: 30\r\nContent-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30,30\r\nContent-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30,30, 30 \r\nContent-Length: 30 ", + "output": 30 + }, + { + "input": "Content-Length: 30,42\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 30,42\r\nContent-Length: 30,42", + "output": null + }, + { + "input": "Content-Length: 42,30", + "output": null + }, + { + "input": "Content-Length: 30,42", + "output": null + }, + { + "input": "Content-Length: 42\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 30\r\nContent-Length: 42", + "output": null + }, + { + "input": "Content-Length: 30,", + "output": null + }, + { + "input": "Content-Length: ,30", + "output": null + }, + { + "input": "Content-Length: 30\r\nContent-Length: \t", + "output": null + }, + { + "input": "Content-Length: \r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: aaaah\r\nContent-Length: nah", + "output": null + }, + { + "input": "Content-Length: aaaah, nah", + "output": null + }, + { + "input": "Content-Length: aaaah\r\nContent-Length: aaaah", + "output": 42 + }, + { + "input": "Content-Length: aaaah, aaaah", + "output": 42 + }, + { + "input": "Content-Length: aaaah", + "output": 42 + }, + { + "input": "Content-Length: 42s", + "output": 42 + }, + { + "input": "Content-Length: 30s", + "output": 42 + }, + { + "input": "Content-Length: -1", + "output": 42 + }, + { + "input": "Content-Length: 0x20", + "output": 42 + }, + { + "input": "Content-Length: 030", + "output": 30 + }, + { + "input": "Content-Length: 030\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 030, 30", + "output": null + }, + { + "input": "Content-Length: \"30\"", + "output": 42 + }, + { + "input": "Content-Length:30\r\nContent-Length:,\r\nContent-Length:30", + "output": null + }, + { + "input": "Content-Length: ", + "output": 42 + } +] diff --git a/test/wpt/tests/fetch/content-length/resources/identical-duplicates.asis b/test/wpt/tests/fetch/content-length/resources/identical-duplicates.asis new file mode 100644 index 0000000..f38c9a4 --- /dev/null +++ b/test/wpt/tests/fetch/content-length/resources/identical-duplicates.asis @@ -0,0 +1,9 @@ +HTTP/1.1 200 BLAH +Test: x +Test: x +Content-Type: text/plain +Content-Type: text/plain +Content-Length: 6 +Content-Length: 6 + +Test. diff --git a/test/wpt/tests/fetch/content-length/too-long.window.js b/test/wpt/tests/fetch/content-length/too-long.window.js new file mode 100644 index 0000000..f8cefaa --- /dev/null +++ b/test/wpt/tests/fetch/content-length/too-long.window.js @@ -0,0 +1,4 @@ +promise_test(async t => { + const result = await fetch(`resources/content-length.py?length=${encodeURIComponent("Content-Length: 50")}`); + await promise_rejects_js(t, TypeError, result.text()); +}, "Content-Length header value of network response exceeds response body"); diff --git a/test/wpt/tests/fetch/content-type/README.md b/test/wpt/tests/fetch/content-type/README.md new file mode 100644 index 0000000..f553b7e --- /dev/null +++ b/test/wpt/tests/fetch/content-type/README.md @@ -0,0 +1,20 @@ +# `resources/content-types.json` + +An array of tests. Each test has these fields: + +* `contentType`: an array of values for the `Content-Type` header. A harness needs to run the test twice if there are multiple values. One time with the values concatenated with `,` followed by a space and one time with multiple `Content-Type` declarations, each on their own line with one of the values, in order. +* `encoding`: the expected encoding, null for the default. +* `mimeType`: the result of extracting a MIME type and serializing it. +* `documentContentType`: the MIME type expected to be exposed in DOM documents. + +(These tests are currently somewhat geared towards browser use, but could be generalized easily enough if someone wanted to contribute tests for MIME types that would cause downloads in the browser or some such.) + +# `resources/script-content-types.json` + +An array of tests, surprise. Each test has these fields: + +* `contentType`: see above. +* `executes`: whether the script is expected to execute. +* `encoding`: how the script is expected to be decoded. + +These tests are expected to be loaded through ` + +
+ diff --git a/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html new file mode 100644 index 0000000..a771ed6 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html @@ -0,0 +1,4 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html new file mode 100644 index 0000000..82adc47 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub-ref.html b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub-ref.html new file mode 100644 index 0000000..ebb337d --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub-ref.html @@ -0,0 +1,4 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub.html b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub.html new file mode 100644 index 0000000..1ae4cfc --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-png-mislabeled-as-html.sub.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html b/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html new file mode 100644 index 0000000..3219fed --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html @@ -0,0 +1,7 @@ + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html b/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html new file mode 100644 index 0000000..efcfaa2 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html @@ -0,0 +1,11 @@ + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-invalid.sub-ref.html b/test/wpt/tests/fetch/corb/img-svg-invalid.sub-ref.html new file mode 100644 index 0000000..484cd0a --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-invalid.sub-ref.html @@ -0,0 +1,5 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-labeled-as-dash.sub.html b/test/wpt/tests/fetch/corb/img-svg-labeled-as-dash.sub.html new file mode 100644 index 0000000..0578b83 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-labeled-as-dash.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-labeled-as-svg-xml.sub.html b/test/wpt/tests/fetch/corb/img-svg-labeled-as-svg-xml.sub.html new file mode 100644 index 0000000..30a2eb3 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-labeled-as-svg-xml.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg-xml-decl.sub.html b/test/wpt/tests/fetch/corb/img-svg-xml-decl.sub.html new file mode 100644 index 0000000..0d3aeaf --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg-xml-decl.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/wpt/tests/fetch/corb/img-svg.sub-ref.html b/test/wpt/tests/fetch/corb/img-svg.sub-ref.html new file mode 100644 index 0000000..5462f68 --- /dev/null +++ b/test/wpt/tests/fetch/corb/img-svg.sub-ref.html @@ -0,0 +1,5 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html b/test/wpt/tests/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html new file mode 100644 index 0000000..cea80f2 --- /dev/null +++ b/test/wpt/tests/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html @@ -0,0 +1,24 @@ + + + + + +
+ + + + + diff --git a/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css new file mode 100644 index 0000000..afd2b92 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css @@ -0,0 +1 @@ +#header { color: red; } diff --git a/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css new file mode 100644 index 0000000..afd2b92 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css @@ -0,0 +1 @@ +#header { color: red; } diff --git a/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css.headers b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/css-mislabeled-as-html.css.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/css-with-json-parser-breaker.css b/test/wpt/tests/fetch/corb/resources/css-with-json-parser-breaker.css new file mode 100644 index 0000000..7db6f5c --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/css-with-json-parser-breaker.css @@ -0,0 +1,3 @@ +)]}' +{} +#header { color: red; } diff --git a/test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png b/test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png new file mode 100644 index 0000000..e69de29 diff --git a/test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png.headers b/test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png.headers new file mode 100644 index 0000000..e7be84a --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/empty-labeled-as-png.png.headers @@ -0,0 +1 @@ +Content-Type: image/png diff --git a/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html b/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html new file mode 100644 index 0000000..7bad71b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html @@ -0,0 +1,10 @@ + + + + + Page Title + + +

Page body

+ + diff --git a/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html.headers b/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-correctly-labeled.html.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js b/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js new file mode 100644 index 0000000..db45bb4 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js @@ -0,0 +1,9 @@ + diff --git a/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js.headers b/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-js-polyglot.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js b/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js new file mode 100644 index 0000000..faae1b7 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js @@ -0,0 +1,10 @@ + diff --git a/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js.headers b/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/html-js-polyglot2.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js.headers b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/js-mislabeled-as-html.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png b/test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png.headers b/test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png.headers new file mode 100644 index 0000000..e7be84a --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/png-correctly-labeled.png.headers @@ -0,0 +1 @@ +Content-Type: image/png diff --git a/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png.headers b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/png-mislabeled-as-html.png.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/corb/resources/response_block_probe.js b/test/wpt/tests/fetch/corb/resources/response_block_probe.js new file mode 100644 index 0000000..9c3b87b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/response_block_probe.js @@ -0,0 +1 @@ +alert(1); // Arbitrary JavaScript. Details don't matter for the test. diff --git a/test/wpt/tests/fetch/corb/resources/response_block_probe.js.headers b/test/wpt/tests/fetch/corb/resources/response_block_probe.js.headers new file mode 100644 index 0000000..0d848b0 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/response_block_probe.js.headers @@ -0,0 +1 @@ +Content-Type: text/csv diff --git a/test/wpt/tests/fetch/corb/resources/sniffable-resource.py b/test/wpt/tests/fetch/corb/resources/sniffable-resource.py new file mode 100644 index 0000000..f815093 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/sniffable-resource.py @@ -0,0 +1,11 @@ +def main(request, response): + body = request.GET.first(b"body", None) + type = request.GET.first(b"type", None) + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-length", len(body)) + response.writer.write_header(b"content-type", type) + response.writer.end_headers() + + response.writer.write(body) diff --git a/test/wpt/tests/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html b/test/wpt/tests/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html new file mode 100644 index 0000000..67b3ad5 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html @@ -0,0 +1,16 @@ + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg new file mode 100644 index 0000000..fa2d29b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers new file mode 100644 index 0000000..29515ee --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers @@ -0,0 +1 @@ +Content-Type: diff --git a/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg new file mode 100644 index 0000000..fa2d29b --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg.headers b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg.headers new file mode 100644 index 0000000..43ce612 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-dash.svg.headers @@ -0,0 +1 @@ +Content-Type: application/dash+xml diff --git a/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/wpt/tests/fetch/corb/resources/svg-xml-decl.svg b/test/wpt/tests/fetch/corb/resources/svg-xml-decl.svg new file mode 100644 index 0000000..3b39aff --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg-xml-decl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg.svg b/test/wpt/tests/fetch/corb/resources/svg.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/wpt/tests/fetch/corb/resources/svg.svg.headers b/test/wpt/tests/fetch/corb/resources/svg.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/wpt/tests/fetch/corb/resources/svg.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/wpt/tests/fetch/corb/response_block.tentative.https.html b/test/wpt/tests/fetch/corb/response_block.tentative.https.html new file mode 100644 index 0000000..6b11600 --- /dev/null +++ b/test/wpt/tests/fetch/corb/response_block.tentative.https.html @@ -0,0 +1,50 @@ + + + + + + diff --git a/test/wpt/tests/fetch/corb/script-html-correctly-labeled.tentative.sub.html b/test/wpt/tests/fetch/corb/script-html-correctly-labeled.tentative.sub.html new file mode 100644 index 0000000..6d1947c --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-html-correctly-labeled.tentative.sub.html @@ -0,0 +1,32 @@ + + + + + +
+ diff --git a/test/wpt/tests/fetch/corb/script-html-js-polyglot.sub.html b/test/wpt/tests/fetch/corb/script-html-js-polyglot.sub.html new file mode 100644 index 0000000..9a272d6 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-html-js-polyglot.sub.html @@ -0,0 +1,32 @@ + + + + + +
+ diff --git a/test/wpt/tests/fetch/corb/script-html-via-cross-origin-blob-url.sub.html b/test/wpt/tests/fetch/corb/script-html-via-cross-origin-blob-url.sub.html new file mode 100644 index 0000000..c8a90c7 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-html-via-cross-origin-blob-url.sub.html @@ -0,0 +1,38 @@ + + + + + +
+ diff --git a/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html b/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html new file mode 100644 index 0000000..b6bc909 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html @@ -0,0 +1,33 @@ + + + + + +
+ + + + + + + diff --git a/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html.sub.html b/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html.sub.html new file mode 100644 index 0000000..44cb1f8 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-js-mislabeled-as-html.sub.html @@ -0,0 +1,25 @@ + + + + + +
+ + + + + + + diff --git a/test/wpt/tests/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html b/test/wpt/tests/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html new file mode 100644 index 0000000..f0eb1f0 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html @@ -0,0 +1,85 @@ + + + + + +
+ diff --git a/test/wpt/tests/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html b/test/wpt/tests/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html new file mode 100644 index 0000000..6d490d5 --- /dev/null +++ b/test/wpt/tests/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html @@ -0,0 +1,84 @@ + + + + + + +
+ diff --git a/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html b/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html new file mode 100644 index 0000000..8fef0dc --- /dev/null +++ b/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html @@ -0,0 +1,42 @@ + + + +CSS is not applied (because of nosniff + non-text/css headers) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html.sub.html b/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html.sub.html new file mode 100644 index 0000000..4f0b4c2 --- /dev/null +++ b/test/wpt/tests/fetch/corb/style-css-mislabeled-as-html.sub.html @@ -0,0 +1,36 @@ + + + +CSS is not applied (because of strict content-type enforcement for cross-origin stylesheets) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/wpt/tests/fetch/corb/style-css-with-json-parser-breaker.sub.html b/test/wpt/tests/fetch/corb/style-css-with-json-parser-breaker.sub.html new file mode 100644 index 0000000..29ed586 --- /dev/null +++ b/test/wpt/tests/fetch/corb/style-css-with-json-parser-breaker.sub.html @@ -0,0 +1,38 @@ + + + +CORB doesn't block a stylesheet that has a proper Content-Type and begins with a JSON parser breaker + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/wpt/tests/fetch/corb/style-html-correctly-labeled.sub.html b/test/wpt/tests/fetch/corb/style-html-correctly-labeled.sub.html new file mode 100644 index 0000000..cdefcd2 --- /dev/null +++ b/test/wpt/tests/fetch/corb/style-html-correctly-labeled.sub.html @@ -0,0 +1,41 @@ + + + +CSS is not applied (because of mismatched Content-Type header) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/fetch-in-iframe.html b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch-in-iframe.html new file mode 100644 index 0000000..cc6a3a8 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch-in-iframe.html @@ -0,0 +1,67 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.any.js b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.any.js new file mode 100644 index 0000000..64a7bfe --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.any.js @@ -0,0 +1,76 @@ +// META: timeout=long +// META: global=window,dedicatedworker,sharedworker +// META: script=/common/get-host-info.sub.js + +const host = get_host_info(); +const path = "/fetch/cross-origin-resource-policy/"; +const localBaseURL = host.HTTP_ORIGIN + path; +const sameSiteBaseURL = "http://" + host.ORIGINAL_HOST + ":" + host.HTTP_PORT2 + path; +const notSameSiteBaseURL = host.HTTP_NOTSAMESITE_ORIGIN + path; +const httpsBaseURL = host.HTTPS_ORIGIN + path; + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = httpsBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-scheme (HTTP to HTTPS) no-cors fetch to a same-site URL with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = httpsBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch to a same-site URL with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const remoteSameSiteURL = sameSiteBaseURL + "resources/hello.py?corp=same-site"; + + await fetch(remoteSameSiteURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(sameSiteBaseURL + "resources/hello.py?corp=same-origin", { mode: "no-cors" })); +}, "Valid cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const finalURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch("resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a redirection."); + +promise_test((test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + return fetch(notSameSiteBaseURL + "resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" }); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a cross-origin redirection."); + +promise_test(async (test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + + await fetch(finalURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(notSameSiteBaseURL + "resources/redirect.py?corp=same-origin&redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' redirect response header."); diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.https.any.js b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.https.any.js new file mode 100644 index 0000000..c9b5b75 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/fetch.https.any.js @@ -0,0 +1,56 @@ +// META: timeout=long +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const host = get_host_info(); +const path = "/fetch/cross-origin-resource-policy/"; +const localBaseURL = host.HTTPS_ORIGIN + path; +const notSameSiteBaseURL = host.HTTPS_NOTSAMESITE_ORIGIN + path; + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const finalURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch("resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a redirection."); + +promise_test((test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + return fetch(notSameSiteBaseURL + "resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" }); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a cross-origin redirection."); + +promise_test(async (test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + + await fetch(finalURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(notSameSiteBaseURL + "resources/redirect.py?corp=same-origin&redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' redirect response header."); diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/iframe-loads.html b/test/wpt/tests/fetch/cross-origin-resource-policy/iframe-loads.html new file mode 100644 index 0000000..63902c3 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/iframe-loads.html @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/image-loads.html b/test/wpt/tests/fetch/cross-origin-resource-policy/image-loads.html new file mode 100644 index 0000000..060b755 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/image-loads.html @@ -0,0 +1,54 @@ + + + + + + + + +
+ + + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/resources/green.png b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/green.png new file mode 100644 index 0000000000000000000000000000000000000000..28a1faab37797ef39454aa1deac1b470712f7be4 GIT binary patch literal 87 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*$P6SW{C@KnNHGWagt#*NXE2F7umZ^C_jGX# j(GX2ekYHV$kio>jw1

The iframe

" + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/resources/iframeFetch.html b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/iframeFetch.html new file mode 100644 index 0000000..2571858 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/iframeFetch.html @@ -0,0 +1,19 @@ + + + + + + +

The iframe making a same origin fetch call.

+ + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/resources/image.py b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/image.py new file mode 100644 index 0000000..2a779cf --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/image.py @@ -0,0 +1,22 @@ +import os.path + +from wptserve.utils import isomorphic_decode + +def main(request, response): + type = request.GET.first(b"type", None) + + body = open(os.path.join(os.path.dirname(isomorphic_decode(__file__)), u"green.png"), u"rb").read() + + response.add_required_headers = False + response.writer.write_status(200) + + if b'corp' in request.GET: + response.writer.write_header(b"cross-origin-resource-policy", request.GET[b'corp']) + if b'acao' in request.GET: + response.writer.write_header(b"access-control-allow-origin", request.GET[b'acao']) + response.writer.write_header(b"content-length", len(body)) + if(type != None): + response.writer.write_header(b"content-type", type) + response.writer.end_headers() + + response.writer.write(body) diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/resources/redirect.py b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/redirect.py new file mode 100644 index 0000000..0dad4dd --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/redirect.py @@ -0,0 +1,6 @@ +def main(request, response): + headers = [(b"Location", request.GET[b'redirectTo'])] + if b'corp' in request.GET: + headers.append((b'Cross-Origin-Resource-Policy', request.GET[b'corp'])) + + return 302, headers, b"" diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/resources/script.py b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/script.py new file mode 100644 index 0000000..58f8d34 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/resources/script.py @@ -0,0 +1,6 @@ +def main(request, response): + headers = [(b"Cross-Origin-Resource-Policy", request.GET[b'corp'])] + if b'origin' in request.headers: + headers.append((b'Access-Control-Allow-Origin', request.headers[b'origin'])) + + return 200, headers, b"" diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.any.js b/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.any.js new file mode 100644 index 0000000..8f63381 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.any.js @@ -0,0 +1,7 @@ +// META: script=/common/get-host-info.sub.js + +promise_test(t => { + return promise_rejects_js(t, + TypeError, + fetch(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/hello.py?corp=same-site", { mode: "no-cors" })); +}, "Cross-Origin-Resource-Policy: same-site blocks retrieving HTTPS from HTTP"); diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js b/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js new file mode 100644 index 0000000..4c74571 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js @@ -0,0 +1,13 @@ +// META: script=/common/get-host-info.sub.js + +promise_test(t => { + const img = new Image(); + img.src = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/image.py?corp=same-site"; + return new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + document.body.appendChild(img); + }).finally(() => { + img.remove(); + }); +}, "Cross-Origin-Resource-Policy does not block Mixed Content "); diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/script-loads.html b/test/wpt/tests/fetch/cross-origin-resource-policy/script-loads.html new file mode 100644 index 0000000..a9690fc --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/script-loads.html @@ -0,0 +1,52 @@ + + + + + + + + +
+ + + diff --git a/test/wpt/tests/fetch/cross-origin-resource-policy/syntax.any.js b/test/wpt/tests/fetch/cross-origin-resource-policy/syntax.any.js new file mode 100644 index 0000000..dc87497 --- /dev/null +++ b/test/wpt/tests/fetch/cross-origin-resource-policy/syntax.any.js @@ -0,0 +1,19 @@ +// META: script=/common/get-host-info.sub.js + +const crossOriginURL = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/hello.py?corp="; + +[ + "same", + "same, same-origin", + "SAME-ORIGIN", + "Same-Origin", + "same-origin, <>", + "same-origin, same-origin", + "https://www.example.com", // See https://github.com/whatwg/fetch/issues/760 +].forEach(incorrectHeaderValue => { + // Note: an incorrect value results in a successful load, so this test is only meaningful in + // implementations with support for the header. + promise_test(t => { + return fetch(crossOriginURL + encodeURIComponent(incorrectHeaderValue), { mode: "no-cors" }); + }, "Parsing Cross-Origin-Resource-Policy: " + incorrectHeaderValue); +}); diff --git a/test/wpt/tests/fetch/data-urls/README.md b/test/wpt/tests/fetch/data-urls/README.md new file mode 100644 index 0000000..1ce5b18 --- /dev/null +++ b/test/wpt/tests/fetch/data-urls/README.md @@ -0,0 +1,11 @@ +## data: URLs + +`resources/data-urls.json` contains `data:` URL tests. The tests are encoded as a JSON array. Each value in the array is an array of two or three values. The first value describes the input, the second value describes the expected MIME type, null if the input is expected to fail somehow, or the empty string if the expected value is `text/plain;charset=US-ASCII`. The third value, if present, describes the expected body as an array of integers representing bytes. + +These tests are used for `data:` URLs in this directory (see `processing.any.js`). + +## Forgiving-base64 decode + +`resources/base64.json` contains [forgiving-base64 decode](https://infra.spec.whatwg.org/#forgiving-base64-decode) tests. The tests are encoded as a JSON array. Each value in the array is an array of two values. The first value describes the input, the second value describes the output as an array of integers representing bytes or null if the input cannot be decoded. + +These tests are used for `data:` URLs in this directory (see `base64.any.js`) and `window.atob()` in `../../html/webappapis/atob/base64.html`. diff --git a/test/wpt/tests/fetch/data-urls/base64.any.js b/test/wpt/tests/fetch/data-urls/base64.any.js new file mode 100644 index 0000000..83f34db --- /dev/null +++ b/test/wpt/tests/fetch/data-urls/base64.any.js @@ -0,0 +1,18 @@ +// META: global=window,worker + +promise_test(() => fetch("resources/base64.json").then(res => res.json()).then(runBase64Tests), "Setup."); +function runBase64Tests(tests) { + for(let i = 0; i < tests.length; i++) { + const input = tests[i][0], + output = tests[i][1], + dataURL = "data:;base64," + input; + promise_test(t => { + if(output === null) { + return promise_rejects_js(t, TypeError, fetch(dataURL)); + } + return fetch(dataURL).then(res => res.arrayBuffer()).then(body => { + assert_array_equals(new Uint8Array(body), output); + }); + }, "data: URL base64 handling: " + format_value(input)); + } +} diff --git a/test/wpt/tests/fetch/data-urls/navigate.window.js b/test/wpt/tests/fetch/data-urls/navigate.window.js new file mode 100644 index 0000000..b532a00 --- /dev/null +++ b/test/wpt/tests/fetch/data-urls/navigate.window.js @@ -0,0 +1,75 @@ +// META: timeout=long +// +// Test some edge cases around navigation to data: URLs to ensure they use the same code path + +[ + { + input: "data:text/html,", + result: 1, + name: "Nothing fancy", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoMiwgJyonKTwvc2NyaXB0Pg==", + result: 2, + name: "base64", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNCwgJyonKTwvc2NyaXB0Pr+/", + result: 4, + name: "base64 with code points that differ from base64url" + }, + { + input: "data:text/html;base64,PHNjcml%09%20%20%0A%0C%0DwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNiwgJyonKTwvc2NyaXB0Pg==", + result: 6, + name: "ASCII whitespace in the input is removed" + } +].forEach(({ input, result, name }) => { + // Use promise_test so they go sequentially + promise_test(async t => { + const event = await new Promise((resolve, reject) => { + self.addEventListener("message", t.step_func(resolve), { once: true }); + const frame = document.body.appendChild(document.createElement("iframe")); + t.add_cleanup(() => frame.remove()); + + // The assumption is that postMessage() is quicker + t.step_timeout(reject, 500); + frame.src = input; + }); + assert_equals(event.data, result); + }, name); +}); + +// Failure cases +[ + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoMywgJyonKTwvc2NyaXB0Pg=", + name: "base64 with incorrect padding", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNSwgJyonKTwvc2NyaXB0Pr-_", + name: "base64url is not supported" + }, + { + input: "data:text/html;base64,%0BPHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNywgJyonKTwvc2NyaXB0Pg==", + name: "Vertical tab in the input leads to an error" + } +].forEach(({ input, name }) => { + // Continue to use promise_test so they go sequentially + promise_test(async t => { + const event = await new Promise((resolve, reject) => { + self.addEventListener("message", t.step_func(reject), { once: true }); + const frame = document.body.appendChild(document.createElement("iframe")); + t.add_cleanup(() => frame.remove()); + + // The assumption is that postMessage() is quicker + t.step_timeout(resolve, 500); + frame.src = input; + }); + }, name); +}); + +// I found some of the interesting code point cases above through brute force: +// +// for (i = 0; i < 256; i++) { +// w(btoa("This is a document." diff --git a/test/wpt/tests/fetch/h1-parsing/resources/message.py b/test/wpt/tests/fetch/h1-parsing/resources/message.py new file mode 100644 index 0000000..640080c --- /dev/null +++ b/test/wpt/tests/fetch/h1-parsing/resources/message.py @@ -0,0 +1,3 @@ +def main(request, response): + response.writer.write(request.GET.first(b"message")) + response.close_connection = True diff --git a/test/wpt/tests/fetch/h1-parsing/resources/script-with-0x00-in-header.py b/test/wpt/tests/fetch/h1-parsing/resources/script-with-0x00-in-header.py new file mode 100644 index 0000000..39f58d8 --- /dev/null +++ b/test/wpt/tests/fetch/h1-parsing/resources/script-with-0x00-in-header.py @@ -0,0 +1,4 @@ +def main(request, response): + response.headers.set(b"Content-Type", b"text/javascript") + response.headers.set(b"Custom", b"\0") + return b"var thisIsJavaScript = 0" diff --git a/test/wpt/tests/fetch/h1-parsing/resources/status-code.py b/test/wpt/tests/fetch/h1-parsing/resources/status-code.py new file mode 100644 index 0000000..5421893 --- /dev/null +++ b/test/wpt/tests/fetch/h1-parsing/resources/status-code.py @@ -0,0 +1,6 @@ +def main(request, response): + output = b"HTTP/1.1 " + output += request.GET.first(b"input") + output += b"\nheader-parsing: is sad\n" + response.writer.write(output) + response.close_connection = True diff --git a/test/wpt/tests/fetch/h1-parsing/status-code.window.js b/test/wpt/tests/fetch/h1-parsing/status-code.window.js new file mode 100644 index 0000000..5776cf4 --- /dev/null +++ b/test/wpt/tests/fetch/h1-parsing/status-code.window.js @@ -0,0 +1,98 @@ +[ + { + input: "", + expected: null + }, + { + input: "BLAH", + expected: null + }, + { + input: "0 OK", + expected: { + status: 0, + statusText: "OK" + } + }, + { + input: "1 OK", + expected: { + status: 1, + statusText: "OK" + } + }, + { + input: "99 NOT OK", + expected: { + status: 99, + statusText: "NOT OK" + } + }, + { + input: "077 77", + expected: { + status: 77, + statusText: "77" + } + }, + { + input: "099 HELLO", + expected: { + status: 99, + statusText: "HELLO" + } + }, + { + input: "200", + expected: { + status: 200, + statusText: "" + } + }, + { + input: "999 DOES IT MATTER", + expected: { + status: 999, + statusText: "DOES IT MATTER" + } + }, + { + input: "1000 BOO", + expected: null + }, + { + input: "0200 BOO", + expected: null + }, + { + input: "65736 NOT 200 OR SOME SUCH", + expected: null + }, + { + input: "131072 HI", + expected: null + }, + { + input: "-200 TEST", + expected: null + }, + { + input: "0xA", + expected: null + }, + { + input: "C8", + expected: null + } +].forEach(({ description, input, expected }) => { + promise_test(async t => { + if (expected !== null) { + const response = await fetch("resources/status-code.py?input=" + input); + assert_equals(response.status, expected.status); + assert_equals(response.statusText, expected.statusText); + assert_equals(response.headers.get("header-parsing"), "is sad"); + } else { + await promise_rejects_js(t, TypeError, fetch("resources/status-code.py?input=" + input)); + } + }, `HTTP/1.1 ${input} ${expected === null ? "(network error)" : ""}`); +}); diff --git a/test/wpt/tests/fetch/http-cache/304-update.any.js b/test/wpt/tests/fetch/http-cache/304-update.any.js new file mode 100644 index 0000000..15484f0 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/304-update.any.js @@ -0,0 +1,146 @@ +// META: global=window,worker +// META: title=HTTP Cache - 304 Updates +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache updates returned headers from a Last-Modified 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["Last-Modified", -3000], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", -3000], + ["Last-Modified", -3000], + ["Test-Header", "B"] + ], + expected_type: "lm_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates stored headers from a Last-Modified 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["Last-Modified", -3000], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["Last-Modified", -3000], + ["Test-Header", "B"] + ], + expected_type: "lm_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates returned headers from a ETag 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "ABC"], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", -3000], + ["ETag", "ABC"], + ["Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates stored headers from a ETag 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "DEF"], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["ETag", "DEF"], + ["Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "Content-* header", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "GHI"], + ["Content-Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["ETag", "GHI"], + ["Content-Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Content-Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Content-Test-Header", "B"] + ] + } + ] + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/README.md b/test/wpt/tests/fetch/http-cache/README.md new file mode 100644 index 0000000..512c422 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/README.md @@ -0,0 +1,72 @@ +## HTTP Caching Tests + +These tests cover HTTP-specified behaviours for caches, primarily from +[RFC9111](https://www.rfc-editor.org/rfc/rfc9111.html), but as seen through the +lens of Fetch. + +A few notes: + +* By its nature, [caching is entirely optional]( + https://www.rfc-editor.org/rfc/rfc9111.html#section-2-2); + some tests expecting a response to be + cached might fail because the client chose not to cache it, or chose to + race the cache with a network request. + +* Likewise, some tests might fail because there is a separate document-level + cache that's not well defined; see [this + issue](https://github.com/whatwg/fetch/issues/354). + +* [Partial content tests](partial.any.js) (a.k.a. Range requests) are not specified + in Fetch; tests are included here for interest only. + +* Some browser caches will behave differently when reloading / + shift-reloading, despite the `cache mode` staying the same. + +* [cache-tests.fyi](https://cache-tests.fyi/) is another test suite of HTTP caching + which also caters to server/CDN implementations. + +## Test Format + +Each test run gets its own URL and randomized content and operates independently. + +Each test is an an array of objects, with the following members: + +- `name` - The name of the test. +- `requests` - a list of request objects (see below). + +Possible members of a request object: + +- template - A template object for the request, by name. +- request_method - A string containing the HTTP method to be used. +- request_headers - An array of `[header_name_string, header_value_string]` arrays to + emit in the request. +- request_body - A string to use as the request body. +- mode - The mode string to pass to `fetch()`. +- credentials - The credentials string to pass to `fetch()`. +- cache - The cache string to pass to `fetch()`. +- pause_after - Boolean controlling a 3-second pause after the request completes. +- response_status - A `[number, string]` array containing the HTTP status code + and phrase to return. +- response_headers - An array of `[header_name_string, header_value_string]` arrays to + emit in the response. These values will also be checked like + expected_response_headers, unless there is a third value that is + `false`. See below for special handling considerations. +- response_body - String to send as the response body. If not set, it will contain + the test identifier. +- expected_type - One of `["cached", "not_cached", "lm_validate", "etag_validate", "error"]` +- expected_status - A number representing a HTTP status code to check the response for. + If not set, the value of `response_status[0]` will be used; if that + is not set, 200 will be used. +- expected_request_headers - An array of `[header_name_string, header_value_string]` representing + headers to check the request for. +- expected_response_headers - An array of `[header_name_string, header_value_string]` representing + headers to check the response for. See also response_headers. +- expected_response_text - A string to check the response body against. If not present, `response_body` will be checked if present and non-null; otherwise the response body will be checked for the test uuid (unless the status code disallows a body). Set to `null` to disable all response body checking. + +Some headers in `response_headers` are treated specially: + +* For date-carrying headers, if the value is a number, it will be interpreted as a delta to the time of the first request at the server. +* For URL-carrying headers, the value will be appended as a query parameter for `target`. + +See the source for exact details. + diff --git a/test/wpt/tests/fetch/http-cache/basic-auth-cache-test-ref.html b/test/wpt/tests/fetch/http-cache/basic-auth-cache-test-ref.html new file mode 100644 index 0000000..905facd --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/basic-auth-cache-test-ref.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/wpt/tests/fetch/http-cache/basic-auth-cache-test.html b/test/wpt/tests/fetch/http-cache/basic-auth-cache-test.html new file mode 100644 index 0000000..a8979ba --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/basic-auth-cache-test.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/http-cache/cache-mode.any.js b/test/wpt/tests/fetch/http-cache/cache-mode.any.js new file mode 100644 index 0000000..8f406d5 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/cache-mode.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Fetch - Cache Mode +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "Fetch sends Cache-Control: max-age=0 when cache mode is no-cache", + requests: [ + { + cache: "no-cache", + expected_request_headers: [['cache-control', 'max-age=0']] + } + ] + }, + { + name: "Fetch doesn't touch Cache-Control when cache mode is no-cache and Cache-Control is already present", + requests: [ + { + cache: "no-cache", + request_headers: [['cache-control', 'foo']], + expected_request_headers: [['cache-control', 'foo']] + } + ] + }, + { + name: "Fetch sends Cache-Control: no-cache and Pragma: no-cache when cache mode is no-store", + requests: [ + { + cache: "no-store", + expected_request_headers: [ + ['cache-control', 'no-cache'], + ['pragma', 'no-cache'] + ] + } + ] + }, + { + name: "Fetch doesn't touch Cache-Control when cache mode is no-store and Cache-Control is already present", + requests: [ + { + cache: "no-store", + request_headers: [['cache-control', 'foo']], + expected_request_headers: [['cache-control', 'foo']] + } + ] + }, + { + name: "Fetch doesn't touch Pragma when cache mode is no-store and Pragma is already present", + requests: [ + { + cache: "no-store", + request_headers: [['pragma', 'foo']], + expected_request_headers: [['pragma', 'foo']] + } + ] + } +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/cc-request.any.js b/test/wpt/tests/fetch/http-cache/cc-request.any.js new file mode 100644 index 0000000..d556566 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/cc-request.any.js @@ -0,0 +1,202 @@ +// META: global=window,worker +// META: title=HTTP Cache - Cache-Control Request Directives +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache doesn't use aged but fresh response when request contains Cache-Control: max-age=0", + requests: [ + { + template: "fresh", + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-age=0"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use aged but fresh response when request contains Cache-Control: max-age=1", + requests: [ + { + template: "fresh", + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-age=1"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use fresh response with Age header when request contains Cache-Control: max-age that is greater than remaining freshness", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Age", "1800"] + ] + }, + { + request_headers: [ + ["Cache-Control", "max-age=600"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does use aged stale response when request contains Cache-Control: max-stale that permits its use", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1"] + ], + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-stale=1000"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does reuse stale response with Age header when request contains Cache-Control: max-stale that permits its use", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"], + ["Age", "2000"] + ] + }, + { + request_headers: [ + ["Cache-Control", "max-stale=1000"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: min-fresh that wants it fresher", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"] + ] + }, + { + request_headers: [ + ["Cache-Control", "min-fresh=2000"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response with Age header when request contains Cache-Control: min-fresh that wants it fresher", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"], + ["Age", "1000"] + ] + }, + { + request_headers: [ + ["Cache-Control", "min-fresh=1000"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache validates fresh response with Last-Modified when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Last-Modified", -10000] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "lm_validate" + } + ] + }, + { + name: "HTTP cache validates fresh response with ETag when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["ETag", http_content("abc")] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "etag_validate" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: no-store", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-store"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache generates 504 status code when nothing is in cache and request contains Cache-Control: only-if-cached", + requests: [ + { + request_headers: [ + ["Cache-Control", "only-if-cached"] + ], + expected_status: 504, + expected_response_text: null + } + ] + } +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/credentials.tentative.any.js b/test/wpt/tests/fetch/http-cache/credentials.tentative.any.js new file mode 100644 index 0000000..3177092 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/credentials.tentative.any.js @@ -0,0 +1,62 @@ +// META: global=window,worker +// META: title=HTTP Cache - Content +// META: timeout=long +// META: script=/common/utils.js +// META: script=http-cache.js + +// This is a tentative test. +// Firefox behavior is used as expectations. +// +// whatwg/fetch issue: +// https://github.com/whatwg/fetch/issues/1253 +// +// Chrome design doc: +// https://docs.google.com/document/d/1lvbiy4n-GM5I56Ncw304sgvY5Td32R6KHitjRXvkZ6U/edit# + +const request_cacheable = { + request_headers: [], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ], + // TODO(arthursonzogni): The behavior is tested only for same-origin requests. + // It must behave similarly for cross-site and cross-origin requests. The + // problems is the http-cache.js infrastructure returns the + // "Server-Request-Count" as HTTP response headers, which aren't readable for + // CORS requests. + base_url: location.href.replace(/\/[^\/]*$/, '/'), +}; + +const request_credentialled = { ...request_cacheable, credentials: 'include', }; +const request_anonymous = { ...request_cacheable, credentials: 'omit', }; + +const responseIndex = count => { + return { + expected_response_headers: [ + ['Server-Request-Count', count.toString()], + ], + } +}; + +var tests = [ + { + name: 'same-origin: 2xAnonymous, 2xCredentialled, 1xAnonymous', + requests: [ + { ...request_anonymous , ...responseIndex(1)} , + { ...request_anonymous , ...responseIndex(1)} , + { ...request_credentialled , ...responseIndex(2)} , + { ...request_credentialled , ...responseIndex(2)} , + { ...request_anonymous , ...responseIndex(1)} , + ] + }, + { + name: 'same-origin: 2xCredentialled, 2xAnonymous, 1xCredentialled', + requests: [ + { ...request_credentialled , ...responseIndex(1)} , + { ...request_credentialled , ...responseIndex(1)} , + { ...request_anonymous , ...responseIndex(2)} , + { ...request_anonymous , ...responseIndex(2)} , + { ...request_credentialled , ...responseIndex(1)} , + ] + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/freshness.any.js b/test/wpt/tests/fetch/http-cache/freshness.any.js new file mode 100644 index 0000000..6b97c82 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/freshness.any.js @@ -0,0 +1,215 @@ +// META: global=window,worker +// META: title=HTTP Cache - Freshness +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + // response directives + { + name: "HTTP cache reuses a response with a future Expires", + requests: [ + { + response_headers: [ + ["Expires", (30 * 24 * 60 * 60)] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with a past Expires", + requests: [ + { + response_headers: [ + ["Expires", (-30 * 24 * 60 * 60)] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with a present Expires", + requests: [ + { + response_headers: [ + ["Expires", 0] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with an invalid Expires", + requests: [ + { + response_headers: [ + ["Expires", "0"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with Cache-Control: max-age=0", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=0"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age and a past Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Expires", -10000] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age and an invalid Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Expires", "0"] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with Cache-Control: max-age=0 and a future Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=0"], + ["Expires", 10000] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not prefer Cache-Control: s-maxage over Cache-Control: max-age", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1, s-maxage=3600"] + ], + pause_after: true, + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response when the Age header is greater than its freshness lifetime", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Age", "12000"] + ], + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not store a response with Cache-Control: no-store", + requests: [ + { + response_headers: [ + ["Cache-Control", "no-store"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not store a response with Cache-Control: no-store, even with max-age and Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=10000, no-store"], + ["Expires", 10000] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache stores a response with Cache-Control: no-cache, but revalidates upon use", + requests: [ + { + response_headers: [ + ["Cache-Control", "no-cache"], + ["ETag", "abcd"] + ] + }, + { + expected_type: "etag_validated" + } + ] + }, + { + name: "HTTP cache stores a response with Cache-Control: no-cache, but revalidates upon use, even with max-age and Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=10000, no-cache"], + ["Expires", 10000], + ["ETag", "abcd"] + ] + }, + { + expected_type: "etag_validated" + } + ] + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/heuristic.any.js b/test/wpt/tests/fetch/http-cache/heuristic.any.js new file mode 100644 index 0000000..d846131 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/heuristic.any.js @@ -0,0 +1,93 @@ +// META: global=window,worker +// META: title=HTTP Cache - Heuristic Freshness +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache reuses an unknown response with Last-Modified based upon heuristic freshness when Cache-Control: public is present", + requests: [ + { + response_status: [299, "Whatever"], + response_headers: [ + ["Last-Modified", (-3 * 100)], + ["Cache-Control", "public"] + ], + }, + { + expected_type: "cached", + response_status: [299, "Whatever"] + } + ] + }, + { + name: "HTTP cache does not reuse an unknown response with Last-Modified based upon heuristic freshness when Cache-Control: public is not present", + requests: [ + { + response_status: [299, "Whatever"], + response_headers: [ + ["Last-Modified", (-3 * 100)] + ], + }, + { + expected_type: "not_cached" + } + ] + } +]; + +function check_status(status) { + var succeed = status[0]; + var code = status[1]; + var phrase = status[2]; + var body = status[3]; + if (body === undefined) { + body = http_content(code); + } + var expected_type = "not_cached"; + var desired = "does not use" + if (succeed === true) { + expected_type = "cached"; + desired = "reuses"; + } + tests.push( + { + name: "HTTP cache " + desired + " a " + code + " " + phrase + " response with Last-Modified based upon heuristic freshness", + requests: [ + { + response_status: [code, phrase], + response_headers: [ + ["Last-Modified", (-3 * 100)] + ], + response_body: body + }, + { + expected_type: expected_type, + response_status: [code, phrase], + response_body: body + } + ] + } + ) +} +[ + [true, 200, "OK"], + [true, 203, "Non-Authoritative Information"], + [true, 204, "No Content", ""], + [true, 404, "Not Found"], + [true, 405, "Method Not Allowed"], + [true, 410, "Gone"], + [true, 414, "URI Too Long"], + [true, 501, "Not Implemented"] +].forEach(check_status); +[ + [false, 201, "Created"], + [false, 202, "Accepted"], + [false, 403, "Forbidden"], + [false, 502, "Bad Gateway"], + [false, 503, "Service Unavailable"], + [false, 504, "Gateway Timeout"], +].forEach(check_status); +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/http-cache.js b/test/wpt/tests/fetch/http-cache/http-cache.js new file mode 100644 index 0000000..19f1ca9 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/http-cache.js @@ -0,0 +1,274 @@ +/* global btoa fetch token promise_test step_timeout */ +/* global assert_equals assert_true assert_own_property assert_throws_js assert_less_than */ + +const templates = { + 'fresh': { + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + }, + 'stale': { + 'response_headers': [ + ['Expires', -5000], + ['Last-Modified', -100000] + ] + }, + 'lcl_response': { + 'response_headers': [ + ['Location', 'location_target'], + ['Content-Location', 'content_location_target'] + ] + }, + 'location': { + 'query_arg': 'location_target', + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + }, + 'content_location': { + 'query_arg': 'content_location_target', + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + } +} + +const noBodyStatus = new Set([204, 304]) + +function makeTest (test) { + return function () { + var uuid = token() + var requests = expandTemplates(test) + var fetchFunctions = makeFetchFunctions(requests, uuid) + return runTest(fetchFunctions, requests, uuid) + } +} + +function makeFetchFunctions(requests, uuid) { + var fetchFunctions = [] + for (let i = 0; i < requests.length; ++i) { + fetchFunctions.push({ + code: function (idx) { + var config = requests[idx] + var url = makeTestUrl(uuid, config) + var init = fetchInit(requests, config) + return fetch(url, init) + .then(makeCheckResponse(idx, config)) + .then(makeCheckResponseBody(config, uuid), function (reason) { + if ('expected_type' in config && config.expected_type === 'error') { + assert_throws_js(TypeError, function () { throw reason }) + } else { + throw reason + } + }) + }, + pauseAfter: 'pause_after' in requests[i] + }) + } + return fetchFunctions +} + +function runTest(fetchFunctions, requests, uuid) { + var idx = 0 + function runNextStep () { + if (fetchFunctions.length) { + var nextFetchFunction = fetchFunctions.shift() + if (nextFetchFunction.pauseAfter === true) { + return nextFetchFunction.code(idx++) + .then(pause) + .then(runNextStep) + } else { + return nextFetchFunction.code(idx++) + .then(runNextStep) + } + } else { + return Promise.resolve() + } + } + + return runNextStep() + .then(function () { + return getServerState(uuid) + }).then(function (testState) { + checkRequests(requests, testState) + return Promise.resolve() + }) +} + +function expandTemplates (test) { + var rawRequests = test.requests + var requests = [] + for (let i = 0; i < rawRequests.length; i++) { + var request = rawRequests[i] + request.name = test.name + if ('template' in request) { + var template = templates[request['template']] + for (let member in template) { + if (!request.hasOwnProperty(member)) { + request[member] = template[member] + } + } + } + requests.push(request) + } + return requests +} + +function fetchInit (requests, config) { + var init = { + 'headers': [] + } + if ('request_method' in config) init.method = config['request_method'] + // Note: init.headers must be a copy of config['request_headers'] array, + // because new elements are added later. + if ('request_headers' in config) init.headers = [...config['request_headers']]; + if ('name' in config) init.headers.push(['Test-Name', config.name]) + if ('request_body' in config) init.body = config['request_body'] + if ('mode' in config) init.mode = config['mode'] + if ('credentials' in config) init.credentials = config['credentials'] + if ('cache' in config) init.cache = config['cache'] + init.headers.push(['Test-Requests', btoa(JSON.stringify(requests))]) + return init +} + +function makeCheckResponse (idx, config) { + return function checkResponse (response) { + var reqNum = idx + 1 + var resNum = parseInt(response.headers.get('Server-Request-Count')) + if ('expected_type' in config) { + if (config.expected_type === 'error') { + assert_true(false, `Request ${reqNum} doesn't throw an error`) + return response.text() + } + if (config.expected_type === 'cached') { + assert_less_than(resNum, reqNum, `Response ${reqNum} does not come from cache`) + } + if (config.expected_type === 'not_cached') { + assert_equals(resNum, reqNum, `Response ${reqNum} comes from cache`) + } + } + if ('expected_status' in config) { + assert_equals(response.status, config.expected_status, + `Response ${reqNum} status is ${response.status}, not ${config.expected_status}`) + } else if ('response_status' in config) { + assert_equals(response.status, config.response_status[0], + `Response ${reqNum} status is ${response.status}, not ${config.response_status[0]}`) + } else { + assert_equals(response.status, 200, `Response ${reqNum} status is ${response.status}, not 200`) + } + if ('response_headers' in config) { + config.response_headers.forEach(function (header) { + if (header.len < 3 || header[2] === true) { + assert_equals(response.headers.get(header[0]), header[1], + `Response ${reqNum} header ${header[0]} is "${response.headers.get(header[0])}", not "${header[1]}"`) + } + }) + } + if ('expected_response_headers' in config) { + config.expected_response_headers.forEach(function (header) { + assert_equals(response.headers.get(header[0]), header[1], + `Response ${reqNum} header ${header[0]} is "${response.headers.get(header[0])}", not "${header[1]}"`) + }) + } + return response.text() + } +} + +function makeCheckResponseBody (config, uuid) { + return function checkResponseBody (resBody) { + var statusCode = 200 + if ('response_status' in config) { + statusCode = config.response_status[0] + } + if ('expected_response_text' in config) { + if (config.expected_response_text !== null) { + assert_equals(resBody, config.expected_response_text, + `Response body is "${resBody}", not expected "${config.expected_response_text}"`) + } + } else if ('response_body' in config && config.response_body !== null) { + assert_equals(resBody, config.response_body, + `Response body is "${resBody}", not sent "${config.response_body}"`) + } else if (!noBodyStatus.has(statusCode)) { + assert_equals(resBody, uuid, `Response body is "${resBody}", not default "${uuid}"`) + } + } +} + +function checkRequests (requests, testState) { + var testIdx = 0 + for (let i = 0; i < requests.length; ++i) { + var expectedValidatingHeaders = [] + var config = requests[i] + var serverRequest = testState[testIdx] + var reqNum = i + 1 + if ('expected_type' in config) { + if (config.expected_type === 'cached') continue // the server will not see the request + if (config.expected_type === 'etag_validated') { + expectedValidatingHeaders.push('if-none-match') + } + if (config.expected_type === 'lm_validated') { + expectedValidatingHeaders.push('if-modified-since') + } + } + testIdx++ + expectedValidatingHeaders.forEach(vhdr => { + assert_own_property(serverRequest.request_headers, vhdr, + `request ${reqNum} doesn't have ${vhdr} header`) + }) + if ('expected_request_headers' in config) { + config.expected_request_headers.forEach(expectedHdr => { + assert_equals(serverRequest.request_headers[expectedHdr[0].toLowerCase()], expectedHdr[1], + `request ${reqNum} header ${expectedHdr[0]} value is "${serverRequest.request_headers[expectedHdr[0].toLowerCase()]}", not "${expectedHdr[1]}"`) + }) + } + } +} + +function pause () { + return new Promise(function (resolve, reject) { + step_timeout(function () { + return resolve() + }, 3000) + }) +} + +function makeTestUrl (uuid, config) { + var arg = '' + var base_url = '' + if ('base_url' in config) { + base_url = config.base_url + } + if ('query_arg' in config) { + arg = `&target=${config.query_arg}` + } + return `${base_url}resources/http-cache.py?dispatch=test&uuid=${uuid}${arg}` +} + +function getServerState (uuid) { + return fetch(`resources/http-cache.py?dispatch=state&uuid=${uuid}`) + .then(function (response) { + return response.text() + }).then(function (text) { + return JSON.parse(text) || [] + }) +} + +function run_tests (tests) { + tests.forEach(function (test) { + promise_test(makeTest(test), test.name) + }) +} + +var contentStore = {} +function http_content (csKey) { + if (csKey in contentStore) { + return contentStore[csKey] + } else { + var content = btoa(Math.random() * Date.now()) + contentStore[csKey] = content + return content + } +} diff --git a/test/wpt/tests/fetch/http-cache/invalidate.any.js b/test/wpt/tests/fetch/http-cache/invalidate.any.js new file mode 100644 index 0000000..9f8090a --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/invalidate.any.js @@ -0,0 +1,235 @@ +// META: global=window,worker +// META: title=HTTP Cache - Invalidation +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: 'HTTP cache invalidates after a successful response from a POST', + requests: [ + { + template: "fresh" + }, { + request_method: "POST", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate after a failed response from an unsafe request', + requests: [ + { + template: "fresh" + }, { + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from a PUT', + requests: [ + { + template: "fresh" + }, { + template: "fresh", + request_method: "PUT", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from a DELETE', + requests: [ + { + template: "fresh" + }, { + request_method: "DELETE", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from an unknown method', + requests: [ + { + template: "fresh" + }, { + request_method: "FOO", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + + + { + name: 'HTTP cache invalidates Location URL after a successful response from a POST', + requests: [ + { + template: "location" + }, { + request_method: "POST", + request_body: "abc", + template: "lcl_response" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate Location URL after a failed response from an unsafe request', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + template: "location", + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from a PUT', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "PUT", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from a DELETE', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "DELETE", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from an unknown method', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "FOO", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + + + + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a POST', + requests: [ + { + template: "content_location" + }, { + request_method: "POST", + request_body: "abc", + template: "lcl_response" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate Content-Location URL after a failed response from an unsafe request', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + template: "content_location", + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a PUT', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "PUT", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a DELETE', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "DELETE", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from an unknown method', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "FOO", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + } + +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/partial.any.js b/test/wpt/tests/fetch/http-cache/partial.any.js new file mode 100644 index 0000000..3f23b59 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/partial.any.js @@ -0,0 +1,208 @@ +// META: global=window,worker +// META: title=HTTP Cache - Partial Content +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache stores partial content and reuses it", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234", + expected_request_headers: [ + ["Range", "bytes=-5"] + ] + }, + { + request_headers: [ + ["Range", "bytes=-5"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "01234" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (byte-range-spec)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=0-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "01" + }, + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (absent last-byte-pos)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=1-"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "1234567890" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (suffix-byte-range-spec)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ], + response_body: "0123456789A" + }, + { + request_headers: [ + ['Range', "bytes=-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "A" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it with only-if-cached", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=0-1"] + ], + mode: "same-origin", + cache: "only-if-cached", + expected_type: "cached", + expected_status: 206, + expected_response_text: "01" + }, + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (byte-range-spec)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ['Range', "bytes=6-8"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "234" + } + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (absent last-byte-pos)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ["Range", "bytes=6-"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "234" + } + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (suffix-byte-range-spec)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ['Range', "bytes=-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "4" + } + ] + }, + { + name: "HTTP cache stores partial content and completes it", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 0-4/10"] + ], + response_body: "01234" + }, + { + expected_request_headers: [ + ["range", "bytes=5-"] + ] + } + ] + }, +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/post-patch.any.js b/test/wpt/tests/fetch/http-cache/post-patch.any.js new file mode 100644 index 0000000..0a69baa --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/post-patch.any.js @@ -0,0 +1,46 @@ +// META: global=window,worker +// META: title=HTTP Cache - Caching POST and PATCH responses +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache uses content after PATCH request with response containing Content-Location and cache-allowing header", + requests: [ + { + request_method: "PATCH", + request_body: "abc", + response_status: [200, "OK"], + response_headers: [ + ['Cache-Control', "private, max-age=1000"], + ['Content-Location', ""] + ], + response_body: "abc" + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache uses content after POST request with response containing Content-Location and cache-allowing header", + requests: [ + { + request_method: "POST", + request_body: "abc", + response_status: [200, "OK"], + response_headers: [ + ['Cache-Control', "private, max-age=1000"], + ['Content-Location', ""] + ], + response_body: "abc" + }, + { + expected_type: "cached" + } + ] + } +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/resources/http-cache.py b/test/wpt/tests/fetch/http-cache/resources/http-cache.py new file mode 100644 index 0000000..3ab610d --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/resources/http-cache.py @@ -0,0 +1,124 @@ +import datetime +import json +import time +from base64 import b64decode + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +NOTEHDRS = set([u'content-type', u'access-control-allow-origin', u'last-modified', u'etag']) +NOBODYSTATUS = set([204, 304]) +LOCATIONHDRS = set([u'location', u'content-location']) +DATEHDRS = set([u'date', u'expires', u'last-modified']) + +def main(request, response): + dispatch = request.GET.first(b"dispatch", None) + uuid = request.GET.first(b"uuid", None) + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + + if request.method == u"OPTIONS": + return handle_preflight(uuid, request, response) + if not uuid: + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"UUID not found" + if dispatch == b'test': + return handle_test(uuid, request, response) + elif dispatch == b'state': + return handle_state(uuid, request, response) + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"Fallthrough" + +def handle_preflight(uuid, request, response): + response.status = (200, b"OK") + response.headers.set(b"Access-Control-Allow-Origin", request.headers.get(b"origin") or '*') + response.headers.set(b"Access-Control-Allow-Methods", b"GET") + response.headers.set(b"Access-Control-Allow-Headers", request.headers.get(b"Access-Control-Request-Headers") or "*") + response.headers.set(b"Access-Control-Max-Age", b"86400") + return b"Preflight request" + +def handle_state(uuid, request, response): + response.headers.set(b"Content-Type", b"text/plain") + return json.dumps(request.server.stash.take(uuid)) + +def handle_test(uuid, request, response): + server_state = request.server.stash.take(uuid) or [] + try: + requests = json.loads(b64decode(request.headers.get(b'Test-Requests', b""))) + except: + response.status = (400, b"Bad Request") + response.headers.set(b"Content-Type", b"text/plain") + return b"No or bad Test-Requests request header" + config = requests[len(server_state)] + if not config: + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"Config not found" + noted_headers = {} + now = time.time() + for header in config.get(u'response_headers', []): + if header[0].lower() in LOCATIONHDRS: # magic locations + if (len(header[1]) > 0): + header[1] = u"%s&target=%s" % (request.url, header[1]) + else: + header[1] = request.url + if header[0].lower() in DATEHDRS and isinstance(header[1], int): # magic dates + header[1] = http_date(now, header[1]) + response.headers.set(isomorphic_encode(header[0]), isomorphic_encode(header[1])) + if header[0].lower() in NOTEHDRS: + noted_headers[header[0].lower()] = header[1] + state = { + u'now': now, + u'request_method': request.method, + u'request_headers': dict([[isomorphic_decode(h.lower()), isomorphic_decode(request.headers[h])] for h in request.headers]), + u'response_headers': noted_headers + } + server_state.append(state) + request.server.stash.put(uuid, server_state) + + if u"access-control-allow-origin" not in noted_headers: + response.headers.set(b"Access-Control-Allow-Origin", b"*") + if u"content-type" not in noted_headers: + response.headers.set(b"Content-Type", b"text/plain") + response.headers.set(b"Server-Request-Count", len(server_state)) + + code, phrase = config.get(u"response_status", [200, b"OK"]) + if config.get(u"expected_type", u"").endswith(u'validated'): + ref_hdrs = server_state[0][u'response_headers'] + previous_lm = ref_hdrs.get(u'last-modified', False) + if previous_lm and request.headers.get(b"If-Modified-Since", False) == isomorphic_encode(previous_lm): + code, phrase = [304, b"Not Modified"] + previous_etag = ref_hdrs.get(u'etag', False) + if previous_etag and request.headers.get(b"If-None-Match", False) == isomorphic_encode(previous_etag): + code, phrase = [304, b"Not Modified"] + if code != 304: + code, phrase = [999, b'304 Not Generated'] + response.status = (code, phrase) + + content = config.get(u"response_body", uuid) + if code in NOBODYSTATUS: + return b"" + return content + + +def get_header(headers, header_name): + result = None + for header in headers: + if header[0].lower() == header_name.lower(): + result = header[1] + return result + +WEEKDAYS = [u'Mon', u'Tue', u'Wed', u'Thu', u'Fri', u'Sat', u'Sun'] +MONTHS = [None, u'Jan', u'Feb', u'Mar', u'Apr', u'May', u'Jun', u'Jul', + u'Aug', u'Sep', u'Oct', u'Nov', u'Dec'] + +def http_date(now, delta_secs=0): + date = datetime.datetime.utcfromtimestamp(now + delta_secs) + return u"%s, %.2d %s %.4d %.2d:%.2d:%.2d GMT" % ( + WEEKDAYS[date.weekday()], + date.day, + MONTHS[date.month], + date.year, + date.hour, + date.minute, + date.second) diff --git a/test/wpt/tests/fetch/http-cache/resources/securedimage.py b/test/wpt/tests/fetch/http-cache/resources/securedimage.py new file mode 100644 index 0000000..cac9cfe --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/resources/securedimage.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 - + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def main(request, response): + image_url = str.replace(request.url, u"fetch/http-cache/resources/securedimage.py", u"images/green.png") + + if b"authorization" not in request.headers: + response.status = 401 + response.headers.set(b"WWW-Authenticate", b"Basic") + return + else: + auth = request.headers.get(b"Authorization") + if auth != b"Basic dGVzdHVzZXI6dGVzdHBhc3M=": + response.set_error(403, u"Invalid username or password - " + isomorphic_decode(auth)) + return + + response.status = 301 + response.headers.set(b"Location", isomorphic_encode(image_url)) diff --git a/test/wpt/tests/fetch/http-cache/resources/split-cache-popup-with-iframe.html b/test/wpt/tests/fetch/http-cache/resources/split-cache-popup-with-iframe.html new file mode 100644 index 0000000..48b1618 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/resources/split-cache-popup-with-iframe.html @@ -0,0 +1,34 @@ + + + + + HTTP Cache - helper + + + + + + + + + diff --git a/test/wpt/tests/fetch/http-cache/resources/split-cache-popup.html b/test/wpt/tests/fetch/http-cache/resources/split-cache-popup.html new file mode 100644 index 0000000..edb5794 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/resources/split-cache-popup.html @@ -0,0 +1,28 @@ + + + + + HTTP Cache - helper + + + + + + + + + diff --git a/test/wpt/tests/fetch/http-cache/split-cache.html b/test/wpt/tests/fetch/http-cache/split-cache.html new file mode 100644 index 0000000..fe93d2e --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/split-cache.html @@ -0,0 +1,158 @@ + + + + + HTTP Cache - Partioning by site + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/http-cache/status.any.js b/test/wpt/tests/fetch/http-cache/status.any.js new file mode 100644 index 0000000..10c83a2 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/status.any.js @@ -0,0 +1,60 @@ +// META: global=window,worker +// META: title=HTTP Cache - Status Codes +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = []; +function check_status(status) { + var code = status[0]; + var phrase = status[1]; + var body = status[2]; + if (body === undefined) { + body = http_content(code); + } + tests.push({ + name: "HTTP cache goes to the network if it has a stale " + code + " response", + requests: [ + { + template: "stale", + response_status: [code, phrase], + response_body: body + }, { + expected_type: "not_cached", + response_status: [code, phrase], + response_body: body + } + ] + }) + tests.push({ + name: "HTTP cache avoids going to the network if it has a fresh " + code + " response", + requests: [ + { + template: "fresh", + response_status: [code, phrase], + response_body: body + }, { + expected_type: "cached", + response_status: [code, phrase], + response_body: body + } + ] + }) +} +[ + [200, "OK"], + [203, "Non-Authoritative Information"], + [204, "No Content", null], + [299, "Whatever"], + [400, "Bad Request"], + [404, "Not Found"], + [410, "Gone"], + [499, "Whatever"], + [500, "Internal Server Error"], + [502, "Bad Gateway"], + [503, "Service Unavailable"], + [504, "Gateway Timeout"], + [599, "Whatever"] +].forEach(check_status); +run_tests(tests); diff --git a/test/wpt/tests/fetch/http-cache/vary.any.js b/test/wpt/tests/fetch/http-cache/vary.any.js new file mode 100644 index 0000000..2cfd226 --- /dev/null +++ b/test/wpt/tests/fetch/http-cache/vary.any.js @@ -0,0 +1,313 @@ +// META: global=window,worker +// META: title=HTTP Cache - Vary +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache reuses Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + request_headers: [ + ["Foo", "1"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + request_headers: [ + ["Foo", "2"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response when request omits variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't invalidate existing Vary response", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + response_body: http_content('foo_1') + }, + { + request_headers: [ + ["Foo", "2"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + expected_type: "not_cached", + response_body: http_content('foo_2'), + }, + { + request_headers: [ + ["Foo", "1"] + ], + response_body: http_content('foo_1'), + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't pay attention to headers not listed in Vary", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Other", "2"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + }, + { + request_headers: [ + ["Foo", "1"], + ["Other", "3"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache reuses two-way Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use two-way Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + request_headers: [ + ["Foo", "2"], + ["Bar", "abc"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use two-way Vary response when request omits variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses three-way Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use three-way Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "2"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use three-way Vary response when request doesn't match, regardless of header order", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc4"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache uses three-way Vary response when both request and the original request omited a variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response with a field value of '*'", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "*"] + ] + }, + { + request_headers: [ + ["*", "1"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + } +]; +run_tests(tests); diff --git a/test/wpt/tests/fetch/images/canvas-remote-read-remote-image-redirect.html b/test/wpt/tests/fetch/images/canvas-remote-read-remote-image-redirect.html new file mode 100644 index 0000000..4a887f3 --- /dev/null +++ b/test/wpt/tests/fetch/images/canvas-remote-read-remote-image-redirect.html @@ -0,0 +1,28 @@ + + +Load a no-cors image from a same-origin URL that redirects to a cross-origin URL that redirects to the initial origin + + + + diff --git a/test/wpt/tests/fetch/metadata/META.yml b/test/wpt/tests/fetch/metadata/META.yml new file mode 100644 index 0000000..85f0a7d --- /dev/null +++ b/test/wpt/tests/fetch/metadata/META.yml @@ -0,0 +1,4 @@ +spec: https://w3c.github.io/webappsec-fetch-metadata/ +suggested_reviewers: + - mikewest + - iVanlIsh diff --git a/test/wpt/tests/fetch/metadata/README.md b/test/wpt/tests/fetch/metadata/README.md new file mode 100644 index 0000000..34864d4 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/README.md @@ -0,0 +1,9 @@ +Fetch Metadata Tests +==================== + +This directory contains tests related to the Fetch Metadata proposal: + +: Explainer +:: +: "Spec" +:: diff --git a/test/wpt/tests/fetch/metadata/audio-worklet.https.html b/test/wpt/tests/fetch/metadata/audio-worklet.https.html new file mode 100644 index 0000000..3b768ef --- /dev/null +++ b/test/wpt/tests/fetch/metadata/audio-worklet.https.html @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/embed.https.sub.tentative.html b/test/wpt/tests/fetch/metadata/embed.https.sub.tentative.html new file mode 100644 index 0000000..1900dbd --- /dev/null +++ b/test/wpt/tests/fetch/metadata/embed.https.sub.tentative.html @@ -0,0 +1,63 @@ + + + + + + + + + +
+ + diff --git a/test/wpt/tests/fetch/metadata/fetch-preflight.https.sub.any.js b/test/wpt/tests/fetch/metadata/fetch-preflight.https.sub.any.js new file mode 100644 index 0000000..d524743 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/fetch-preflight.https.sub.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", + { + mode: "cors", + headers: { 'x-test': 'testing' } + }, { + "site": "same-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-site fetch with preflight"); +}, "Same-site fetch with preflight"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", + { + mode: "cors", + headers: { 'x-test': 'testing' } + }, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Cross-site fetch with preflight"); +}, "Cross-site fetch with preflight"); diff --git a/test/wpt/tests/fetch/metadata/fetch.https.sub.any.js b/test/wpt/tests/fetch/metadata/fetch.https.sub.any.js new file mode 100644 index 0000000..aeec5cd --- /dev/null +++ b/test/wpt/tests/fetch/metadata/fetch.https.sub.any.js @@ -0,0 +1,58 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "same-origin", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-origin fetch"); +}, "Same-origin fetch"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "same-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-site fetch"); +}, "Same-site fetch"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Cross-site fetch"); +}, "Cross-site fetch"); + +// Mode +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "same-origin"}, { + "site": "same-origin", + "user": "", + "mode": "same-origin", + "dest": "empty" + }, "Same-origin mode"); +}, "Same-origin mode"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "cors"}, { + "site": "same-origin", + "user": "", + "mode": "cors", + "dest": "empty" + }, "CORS mode"); +}, "CORS mode"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "no-cors"}, { + "site": "same-origin", + "user": "", + "mode": "no-cors", + "dest": "empty" + }, "no-CORS mode"); +}, "no-CORS mode"); diff --git a/test/wpt/tests/fetch/metadata/generated/appcache-manifest.https.sub.html b/test/wpt/tests/fetch/metadata/generated/appcache-manifest.https.sub.html new file mode 100644 index 0000000..cf322fd --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/appcache-manifest.https.sub.html @@ -0,0 +1,341 @@ + + + + + HTTP headers on request for Appcache manifest + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/audioworklet.https.sub.html b/test/wpt/tests/fetch/metadata/generated/audioworklet.https.sub.html new file mode 100644 index 0000000..64fb760 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/audioworklet.https.sub.html @@ -0,0 +1,271 @@ + + + + + HTTP headers on request for AudioWorklet module + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/css-font-face.https.sub.tentative.html b/test/wpt/tests/fetch/metadata/generated/css-font-face.https.sub.tentative.html new file mode 100644 index 0000000..332effe --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/css-font-face.https.sub.tentative.html @@ -0,0 +1,230 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/css-font-face.sub.tentative.html b/test/wpt/tests/fetch/metadata/generated/css-font-face.sub.tentative.html new file mode 100644 index 0000000..8a0b90c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/css-font-face.sub.tentative.html @@ -0,0 +1,196 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/css-images.https.sub.tentative.html b/test/wpt/tests/fetch/metadata/generated/css-images.https.sub.tentative.html new file mode 100644 index 0000000..3fa2401 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/css-images.https.sub.tentative.html @@ -0,0 +1,1384 @@ + + + + + + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/css-images.sub.tentative.html b/test/wpt/tests/fetch/metadata/generated/css-images.sub.tentative.html new file mode 100644 index 0000000..f1ef27c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/css-images.sub.tentative.html @@ -0,0 +1,1099 @@ + + + + + + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-a.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-a.https.sub.html new file mode 100644 index 0000000..dffd36c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-a.https.sub.html @@ -0,0 +1,482 @@ + + + + + + HTTP headers on request for HTML "a" element navigation + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-a.sub.html b/test/wpt/tests/fetch/metadata/generated/element-a.sub.html new file mode 100644 index 0000000..0661de3 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-a.sub.html @@ -0,0 +1,342 @@ + + + + + + HTTP headers on request for HTML "a" element navigation + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-area.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-area.https.sub.html new file mode 100644 index 0000000..be3f5f9 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-area.https.sub.html @@ -0,0 +1,482 @@ + + + + + + HTTP headers on request for HTML "area" element navigation + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-area.sub.html b/test/wpt/tests/fetch/metadata/generated/element-area.sub.html new file mode 100644 index 0000000..5f5c338 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-area.sub.html @@ -0,0 +1,342 @@ + + + + + + HTTP headers on request for HTML "area" element navigation + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-audio.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-audio.https.sub.html new file mode 100644 index 0000000..a9d9512 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-audio.https.sub.html @@ -0,0 +1,325 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-audio.sub.html b/test/wpt/tests/fetch/metadata/generated/element-audio.sub.html new file mode 100644 index 0000000..2b62632 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-audio.sub.html @@ -0,0 +1,229 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-embed.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-embed.https.sub.html new file mode 100644 index 0000000..819bed8 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-embed.https.sub.html @@ -0,0 +1,224 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-embed.sub.html b/test/wpt/tests/fetch/metadata/generated/element-embed.sub.html new file mode 100644 index 0000000..b6e14a5 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-embed.sub.html @@ -0,0 +1,190 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-frame.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-frame.https.sub.html new file mode 100644 index 0000000..17504ff --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-frame.https.sub.html @@ -0,0 +1,309 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-frame.sub.html b/test/wpt/tests/fetch/metadata/generated/element-frame.sub.html new file mode 100644 index 0000000..2d9a7ec --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-frame.sub.html @@ -0,0 +1,250 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-iframe.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-iframe.https.sub.html new file mode 100644 index 0000000..fba1c8b --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-iframe.https.sub.html @@ -0,0 +1,309 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-iframe.sub.html b/test/wpt/tests/fetch/metadata/generated/element-iframe.sub.html new file mode 100644 index 0000000..6f71cc0 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-iframe.sub.html @@ -0,0 +1,250 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.https.sub.html new file mode 100644 index 0000000..a19aa11 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.https.sub.html @@ -0,0 +1,357 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.sub.html b/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.sub.html new file mode 100644 index 0000000..9665872 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-img-environment-change.sub.html @@ -0,0 +1,270 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-img.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-img.https.sub.html new file mode 100644 index 0000000..51d6e08 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-img.https.sub.html @@ -0,0 +1,645 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-img.sub.html b/test/wpt/tests/fetch/metadata/generated/element-img.sub.html new file mode 100644 index 0000000..5a4b152 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-img.sub.html @@ -0,0 +1,456 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-input-image.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-input-image.https.sub.html new file mode 100644 index 0000000..7fa6740 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-input-image.https.sub.html @@ -0,0 +1,229 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-input-image.sub.html b/test/wpt/tests/fetch/metadata/generated/element-input-image.sub.html new file mode 100644 index 0000000..fb2a146 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-input-image.sub.html @@ -0,0 +1,184 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-link-icon.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-link-icon.https.sub.html new file mode 100644 index 0000000..b244960 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-link-icon.https.sub.html @@ -0,0 +1,371 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-link-icon.sub.html b/test/wpt/tests/fetch/metadata/generated/element-link-icon.sub.html new file mode 100644 index 0000000..e9226c1 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-link-icon.sub.html @@ -0,0 +1,279 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html new file mode 100644 index 0000000..bdd684a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html @@ -0,0 +1,559 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.optional.sub.html new file mode 100644 index 0000000..c224488 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-link-prefetch.optional.sub.html @@ -0,0 +1,275 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html new file mode 100644 index 0000000..3a1a8eb --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html @@ -0,0 +1,276 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.optional.sub.html new file mode 100644 index 0000000..df3e92e --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-meta-refresh.optional.sub.html @@ -0,0 +1,225 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-picture.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-picture.https.sub.html new file mode 100644 index 0000000..ba6636a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-picture.https.sub.html @@ -0,0 +1,997 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-picture.sub.html b/test/wpt/tests/fetch/metadata/generated/element-picture.sub.html new file mode 100644 index 0000000..64f851c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-picture.sub.html @@ -0,0 +1,721 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-script.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-script.https.sub.html new file mode 100644 index 0000000..dcdcba2 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-script.https.sub.html @@ -0,0 +1,593 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-script.sub.html b/test/wpt/tests/fetch/metadata/generated/element-script.sub.html new file mode 100644 index 0000000..a252669 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-script.sub.html @@ -0,0 +1,488 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-video-poster.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-video-poster.https.sub.html new file mode 100644 index 0000000..5805b46 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-video-poster.https.sub.html @@ -0,0 +1,243 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-video-poster.sub.html b/test/wpt/tests/fetch/metadata/generated/element-video-poster.sub.html new file mode 100644 index 0000000..e6cc5ee --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-video-poster.sub.html @@ -0,0 +1,198 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-video.https.sub.html b/test/wpt/tests/fetch/metadata/generated/element-video.https.sub.html new file mode 100644 index 0000000..971360d --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-video.https.sub.html @@ -0,0 +1,325 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/element-video.sub.html b/test/wpt/tests/fetch/metadata/generated/element-video.sub.html new file mode 100644 index 0000000..9707413 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/element-video.sub.html @@ -0,0 +1,229 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html b/test/wpt/tests/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html new file mode 100644 index 0000000..22f9309 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html @@ -0,0 +1,683 @@ + + + + + + HTTP headers on request using the "fetch" API and passing through a Serive Worker + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/fetch.https.sub.html b/test/wpt/tests/fetch/metadata/generated/fetch.https.sub.html new file mode 100644 index 0000000..dde1dae --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/fetch.https.sub.html @@ -0,0 +1,302 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/fetch.sub.html b/test/wpt/tests/fetch/metadata/generated/fetch.sub.html new file mode 100644 index 0000000..d28ea9b --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/fetch.sub.html @@ -0,0 +1,220 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/form-submission.https.sub.html b/test/wpt/tests/fetch/metadata/generated/form-submission.https.sub.html new file mode 100644 index 0000000..988b07c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/form-submission.https.sub.html @@ -0,0 +1,522 @@ + + + + + + HTTP headers on request for HTML form navigation + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/form-submission.sub.html b/test/wpt/tests/fetch/metadata/generated/form-submission.sub.html new file mode 100644 index 0000000..f862062 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/form-submission.sub.html @@ -0,0 +1,400 @@ + + + + + + HTTP headers on request for HTML form navigation + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.html b/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.html new file mode 100644 index 0000000..09f0113 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.html @@ -0,0 +1,529 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.tentative.html b/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.tentative.html new file mode 100644 index 0000000..307c37f --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/header-link.https.sub.tentative.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/header-link.sub.html b/test/wpt/tests/fetch/metadata/generated/header-link.sub.html new file mode 100644 index 0000000..8b6cdae --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/header-link.sub.html @@ -0,0 +1,460 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/header-refresh.https.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/header-refresh.https.optional.sub.html new file mode 100644 index 0000000..e63ee42 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/header-refresh.https.optional.sub.html @@ -0,0 +1,273 @@ + + + + + + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/header-refresh.optional.sub.html b/test/wpt/tests/fetch/metadata/generated/header-refresh.optional.sub.html new file mode 100644 index 0000000..4674ada --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/header-refresh.optional.sub.html @@ -0,0 +1,222 @@ + + + + + + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.https.sub.html b/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.https.sub.html new file mode 100644 index 0000000..72d60fc --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.https.sub.html @@ -0,0 +1,254 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.sub.html b/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.sub.html new file mode 100644 index 0000000..088720c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/script-module-import-dynamic.sub.html @@ -0,0 +1,214 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/script-module-import-static.https.sub.html b/test/wpt/tests/fetch/metadata/generated/script-module-import-static.https.sub.html new file mode 100644 index 0000000..cea3464 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/script-module-import-static.https.sub.html @@ -0,0 +1,288 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/script-module-import-static.sub.html b/test/wpt/tests/fetch/metadata/generated/script-module-import-static.sub.html new file mode 100644 index 0000000..0f94f71 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/script-module-import-static.sub.html @@ -0,0 +1,246 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/serviceworker.https.sub.html b/test/wpt/tests/fetch/metadata/generated/serviceworker.https.sub.html new file mode 100644 index 0000000..12e3736 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/serviceworker.https.sub.html @@ -0,0 +1,170 @@ + + + + + + HTTP headers on request for Service Workers + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/svg-image.https.sub.html b/test/wpt/tests/fetch/metadata/generated/svg-image.https.sub.html new file mode 100644 index 0000000..b059eb3 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/svg-image.https.sub.html @@ -0,0 +1,367 @@ + + + + + + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/svg-image.sub.html b/test/wpt/tests/fetch/metadata/generated/svg-image.sub.html new file mode 100644 index 0000000..a28bbb1 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/svg-image.sub.html @@ -0,0 +1,265 @@ + + + + + + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/window-history.https.sub.html b/test/wpt/tests/fetch/metadata/generated/window-history.https.sub.html new file mode 100644 index 0000000..c2b3079 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/window-history.https.sub.html @@ -0,0 +1,237 @@ + + + + + HTTP headers on request for navigation via the HTML History API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/window-history.sub.html b/test/wpt/tests/fetch/metadata/generated/window-history.sub.html new file mode 100644 index 0000000..333d90c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/window-history.sub.html @@ -0,0 +1,360 @@ + + + + + + HTTP headers on request for navigation via the HTML History API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/window-location.https.sub.html b/test/wpt/tests/fetch/metadata/generated/window-location.https.sub.html new file mode 100644 index 0000000..4a0d2fd --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/window-location.https.sub.html @@ -0,0 +1,1184 @@ + + + + + + HTTP headers on request for navigation via the HTML Location API + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/window-location.sub.html b/test/wpt/tests/fetch/metadata/generated/window-location.sub.html new file mode 100644 index 0000000..bb3e680 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/window-location.sub.html @@ -0,0 +1,894 @@ + + + + + + HTTP headers on request for navigation via the HTML Location API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html new file mode 100644 index 0000000..86f1760 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html @@ -0,0 +1,118 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.sub.html b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.sub.html new file mode 100644 index 0000000..69ac768 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-constructor.sub.html @@ -0,0 +1,204 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html new file mode 100644 index 0000000..0cd9f35 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html @@ -0,0 +1,268 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.sub.html b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.sub.html new file mode 100644 index 0000000..0555bba --- /dev/null +++ b/test/wpt/tests/fetch/metadata/generated/worker-dedicated-importscripts.sub.html @@ -0,0 +1,228 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/wpt/tests/fetch/metadata/navigation.https.sub.html b/test/wpt/tests/fetch/metadata/navigation.https.sub.html new file mode 100644 index 0000000..32c9cf7 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/navigation.https.sub.html @@ -0,0 +1,23 @@ + + + + + + diff --git a/test/wpt/tests/fetch/metadata/object.https.sub.html b/test/wpt/tests/fetch/metadata/object.https.sub.html new file mode 100644 index 0000000..fae5b37 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/object.https.sub.html @@ -0,0 +1,62 @@ + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/paint-worklet.https.html b/test/wpt/tests/fetch/metadata/paint-worklet.https.html new file mode 100644 index 0000000..49fc776 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/paint-worklet.https.html @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/portal.https.sub.html b/test/wpt/tests/fetch/metadata/portal.https.sub.html new file mode 100644 index 0000000..55b555a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/portal.https.sub.html @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/preload.https.sub.html b/test/wpt/tests/fetch/metadata/preload.https.sub.html new file mode 100644 index 0000000..29042a8 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/preload.https.sub.html @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html b/test/wpt/tests/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html new file mode 100644 index 0000000..0f8f320 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/redirect/redirect-http-upgrade.sub.html b/test/wpt/tests/fetch/metadata/redirect/redirect-http-upgrade.sub.html new file mode 100644 index 0000000..fa765b6 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/redirect/redirect-http-upgrade.sub.html @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/redirect/redirect-https-downgrade.sub.html b/test/wpt/tests/fetch/metadata/redirect/redirect-https-downgrade.sub.html new file mode 100644 index 0000000..4e5a48e --- /dev/null +++ b/test/wpt/tests/fetch/metadata/redirect/redirect-https-downgrade.sub.html @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/report.https.sub.html b/test/wpt/tests/fetch/metadata/report.https.sub.html new file mode 100644 index 0000000..b65f7c0 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/report.https.sub.html @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/report.https.sub.html.sub.headers b/test/wpt/tests/fetch/metadata/report.https.sub.html.sub.headers new file mode 100644 index 0000000..1ec5df7 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/report.https.sub.html.sub.headers @@ -0,0 +1,3 @@ +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri /fetch/metadata/resources/record-header.py?file=report-same-origin +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/record-header.py?file=report-same-site +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/record-header.py?file=report-cross-site diff --git a/test/wpt/tests/fetch/metadata/resources/appcache-iframe.sub.html b/test/wpt/tests/fetch/metadata/resources/appcache-iframe.sub.html new file mode 100644 index 0000000..cea9a4f --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/appcache-iframe.sub.html @@ -0,0 +1,15 @@ + + + + diff --git a/test/wpt/tests/fetch/metadata/resources/dedicatedWorker.js b/test/wpt/tests/fetch/metadata/resources/dedicatedWorker.js new file mode 100644 index 0000000..18626d3 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/dedicatedWorker.js @@ -0,0 +1 @@ +self.postMessage("Loaded"); diff --git a/test/wpt/tests/fetch/metadata/resources/echo-as-json.py b/test/wpt/tests/fetch/metadata/resources/echo-as-json.py new file mode 100644 index 0000000..44f68e8 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/echo-as-json.py @@ -0,0 +1,29 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [(b"Content-Type", b"application/json"), + (b"Access-Control-Allow-Credentials", b"true")] + + if b"origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers[b"origin"])) + + body = u"" + + # If we're in a preflight, verify that `Sec-Fetch-Mode` is `cors`. + if request.method == u'OPTIONS': + if request.headers.get(b"sec-fetch-mode") != b"cors": + return (403, b"Failed"), [], body + + headers.append((b"Access-Control-Allow-Methods", b"*")) + headers.append((b"Access-Control-Allow-Headers", b"*")) + else: + body = json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + + return headers, body diff --git a/test/wpt/tests/fetch/metadata/resources/echo-as-script.py b/test/wpt/tests/fetch/metadata/resources/echo-as-script.py new file mode 100644 index 0000000..1e7bc91 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/echo-as-script.py @@ -0,0 +1,14 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [(b"Content-Type", b"text/javascript")] + body = u"var header = %s;" % json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + + return headers, body diff --git a/test/wpt/tests/fetch/metadata/resources/es-module.sub.js b/test/wpt/tests/fetch/metadata/resources/es-module.sub.js new file mode 100644 index 0000000..f9668a3 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/es-module.sub.js @@ -0,0 +1 @@ +import '{{GET[moduleId]}}'; diff --git a/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js new file mode 100644 index 0000000..09858b2 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js @@ -0,0 +1,3 @@ +self.addEventListener('fetch', function(event) { + // Empty event handler - will fallback to the network. +}); diff --git a/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js new file mode 100644 index 0000000..8bf8d8f --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js @@ -0,0 +1,3 @@ +self.addEventListener('fetch', function(event) { + event.respondWith(fetch(event.request)); +}); diff --git a/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker-frame.html b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker-frame.html new file mode 100644 index 0000000..9879802 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/fetch-via-serviceworker-frame.html @@ -0,0 +1,3 @@ + + +Page Title diff --git a/test/wpt/tests/fetch/metadata/resources/header-link.py b/test/wpt/tests/fetch/metadata/resources/header-link.py new file mode 100644 index 0000000..de89116 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/header-link.py @@ -0,0 +1,15 @@ +def main(request, response): + """ + Respond with a blank HTML document and a `Link` header which describes + a link relation specified by the requests `location` and `rel` query string + parameters + """ + headers = [ + (b'Content-Type', b'text/html'), + ( + b'Link', + b'<' + request.GET.first(b'location') + b'>; rel=' + request.GET.first(b'rel') + ) + ] + return (200, headers, b'') + diff --git a/test/wpt/tests/fetch/metadata/resources/helper.js b/test/wpt/tests/fetch/metadata/resources/helper.js new file mode 100644 index 0000000..725f9a7 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/helper.js @@ -0,0 +1,42 @@ +function validate_expectations(key, expected, tag) { + return fetch("/fetch/metadata/resources/record-header.py?retrieve=true&file=" + key) + .then(response => response.text()) + .then(text => { + assert_not_equals(text, "No header has been recorded"); + let value = JSON.parse(text); + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); + }); +} + +function validate_expectations_custom_url(url, header, expected, tag) { + return fetch(url, header) + .then(response => response.text()) + .then(text => { + assert_not_equals(text, "No header has been recorded"); + let value = JSON.parse(text); + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); + }); +} + +/** + * @param {object} value + * @param {object} expected + * @param {string} tag + **/ +function assert_header_equals(value, expected, tag) { + if (typeof(value) === "string"){ + assert_not_equals(value, "No header has been recorded"); + value = JSON.parse(value); + } + + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); +} diff --git a/test/wpt/tests/fetch/metadata/resources/helper.sub.js b/test/wpt/tests/fetch/metadata/resources/helper.sub.js new file mode 100644 index 0000000..fd179fe --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/helper.sub.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Construct a URL which, when followed, will trigger redirection through zero + * or more specified origins and ultimately resolve in the Python handler + * `record-headers.py`. + * + * @param {string} key - the WPT server "stash" name where the request's + * headers should be stored + * @param {string[]} [origins] - zero or more origin names through which the + * request should pass; see the function + * implementation for a completel list of names + * and corresponding origins; If specified, the + * final origin will be used to access the + * `record-headers.py` hander. + * @param {object} [params] - a collection of key-value pairs to include as + * URL "search" parameters in the final request to + * `record-headers.py` + * + * @returns {string} an absolute URL + */ +function makeRequestURL(key, origins, params) { + const byName = { + httpOrigin: 'http://{{host}}:{{ports[http][0]}}', + httpSameSite: 'http://{{hosts[][www]}}:{{ports[http][0]}}', + httpCrossSite: 'http://{{hosts[alt][]}}:{{ports[http][0]}}', + httpsOrigin: 'https://{{host}}:{{ports[https][0]}}', + httpsSameSite: 'https://{{hosts[][www]}}:{{ports[https][0]}}', + httpsCrossSite: 'https://{{hosts[alt][]}}:{{ports[https][0]}}' + }; + const redirectPath = '/fetch/api/resources/redirect.py?location='; + const path = '/fetch/metadata/resources/record-headers.py?key=' + key; + + let requestUrl = path; + if (params) { + requestUrl += '&' + new URLSearchParams(params).toString(); + } + + if (origins && origins.length) { + requestUrl = byName[origins.pop()] + requestUrl; + + while (origins.length) { + requestUrl = byName[origins.pop()] + redirectPath + + encodeURIComponent(requestUrl); + } + } else { + requestUrl = byName.httpsOrigin + requestUrl; + } + + return requestUrl; +} + +function retrieve(key, options) { + return fetch('/fetch/metadata/resources/record-headers.py?retrieve&key=' + key) + .then((response) => { + if (response.status === 204 && options && options.poll) { + return new Promise((resolve) => setTimeout(resolve, 300)) + .then(() => retrieve(key, options)); + } + + if (response.status !== 200) { + throw new Error('Failed to query for recorded headers.'); + } + + return response.text().then((text) => JSON.parse(text)); + }); +} diff --git a/test/wpt/tests/fetch/metadata/resources/message-opener.html b/test/wpt/tests/fetch/metadata/resources/message-opener.html new file mode 100644 index 0000000..eb2af7b --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/message-opener.html @@ -0,0 +1,17 @@ + diff --git a/test/wpt/tests/fetch/metadata/resources/post-to-owner.py b/test/wpt/tests/fetch/metadata/resources/post-to-owner.py new file mode 100644 index 0000000..256dd6e --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/post-to-owner.py @@ -0,0 +1,36 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [ + (b"Content-Type", b"text/html"), + (b"Cache-Control", b"no-cache, no-store, must-revalidate") + ] + key = request.GET.first(b"key", None) + + # We serialize the key into JSON, so have to decode it first. + if key is not None: + key = key.decode('utf-8') + + body = u""" + + + + """ % (json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }), json.dumps(key)) + return headers, body diff --git a/test/wpt/tests/fetch/metadata/resources/record-header.py b/test/wpt/tests/fetch/metadata/resources/record-header.py new file mode 100644 index 0000000..29ff2ed --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/record-header.py @@ -0,0 +1,145 @@ +import os +import hashlib +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + ## Get the query parameter (key) from URL ## + ## Tests will record POST requests (CSP Report) and GET (rest) ## + if request.GET: + key = request.GET[b'file'] + elif request.POST: + key = request.POST[b'file'] + + ## Convert the key from String to UUID valid String ## + testId = hashlib.md5(key).hexdigest() + + ## Handle the header retrieval request ## + if b'retrieve' in request.GET: + response.writer.write_status(200) + response.writer.write_header(b"Connection", b"close") + response.writer.end_headers() + try: + header_value = request.server.stash.take(testId) + response.writer.write(header_value) + except (KeyError, ValueError) as e: + response.writer.write(u"No header has been recorded") + pass + + response.close_connection = True + + ## Record incoming fetch metadata header value + else: + try: + ## Return a serialized JSON object with one member per header. If the ## + ## header isn't present, the member will contain an empty string. ## + header = json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + request.server.stash.put(testId, header) + except KeyError: + ## The header is already recorded or it doesn't exist + pass + + ## Prevent the browser from caching returned responses and allow CORS ## + response.headers.set(b"Access-Control-Allow-Origin", b"*") + response.headers.set(b"Cache-Control", b"no-cache, no-store, must-revalidate") + response.headers.set(b"Pragma", b"no-cache") + response.headers.set(b"Expires", b"0") + + ## Add a valid ServiceWorker Content-Type ## + if key.startswith(b"serviceworker"): + response.headers.set(b"Content-Type", b"application/javascript") + + ## Add a valid image Content-Type ## + if key.startswith(b"image"): + response.headers.set(b"Content-Type", b"image/png") + file = open(os.path.join(request.doc_root, u"media", u"1x1-green.png"), u"rb") + image = file.read() + file.close() + return image + + ## Return a valid .vtt content for the tag ## + if key.startswith(b"track"): + return b"WEBVTT" + + ## Return a valid SharedWorker ## + if key.startswith(b"sharedworker"): + response.headers.set(b"Content-Type", b"application/javascript") + file = open(os.path.join(request.doc_root, u"fetch", u"metadata", + u"resources", u"sharedWorker.js"), u"rb") + shared_worker = file.read() + file.close() + return shared_worker + + ## Return a valid font content and Content-Type ## + if key.startswith(b"font"): + response.headers.set(b"Content-Type", b"application/x-font-ttf") + file = open(os.path.join(request.doc_root, u"fonts", u"Ahem.ttf"), u"rb") + font = file.read() + file.close() + return font + + ## Return a valid audio content and Content-Type ## + if key.startswith(b"audio"): + response.headers.set(b"Content-Type", b"audio/mpeg") + file = open(os.path.join(request.doc_root, u"media", u"sound_5.mp3"), u"rb") + audio = file.read() + file.close() + return audio + + ## Return a valid video content and Content-Type ## + if key.startswith(b"video"): + response.headers.set(b"Content-Type", b"video/mp4") + file = open(os.path.join(request.doc_root, u"media", u"A4.mp4"), u"rb") + video = file.read() + file.close() + return video + + ## Return valid style content and Content-Type ## + if key.startswith(b"style"): + response.headers.set(b"Content-Type", b"text/css") + return b"div { }" + + ## Return a valid embed/object content and Content-Type ## + if key.startswith(b"embed") or key.startswith(b"object"): + response.headers.set(b"Content-Type", b"text/html") + return b"EMBED!" + + ## Return a valid image content and Content-Type for redirect requests ## + if key.startswith(b"redirect"): + response.headers.set(b"Content-Type", b"image/jpeg") + file = open(os.path.join(request.doc_root, u"media", u"1x1-green.png"), u"rb") + image = file.read() + file.close() + return image + + ## Return a valid dedicated worker + if key.startswith(b"worker"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"self.postMessage('loaded');" + + ## Return a valid worklet + if key.startswith(b"worklet"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"" + + ## Return a valid XSLT + if key.startswith(b"xslt"): + response.headers.set(b"Content-Type", b"text/xsl") + return b""" + + + + + + +""" + + if key.startswith(b"script"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"void 0;" diff --git a/test/wpt/tests/fetch/metadata/resources/record-headers.py b/test/wpt/tests/fetch/metadata/resources/record-headers.py new file mode 100644 index 0000000..0362fe2 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/record-headers.py @@ -0,0 +1,73 @@ +import os +import uuid +import hashlib +import time +import json + + +def bytes_to_strings(d): + # Recursively convert bytes to strings in `d`. + if not isinstance(d, dict): + if isinstance(d, (tuple,list,set)): + v = [bytes_to_strings(x) for x in d] + return v + else: + if isinstance(d, bytes): + d = d.decode() + return d + + result = {} + for k,v in d.items(): + if isinstance(k, bytes): + k = k.decode() + if isinstance(v, dict): + v = bytes_to_strings(v) + elif isinstance(v, (tuple,list,set)): + v = [bytes_to_strings(x) for x in v] + elif isinstance(v, bytes): + v = v.decode() + result[k] = v + return result + + +def main(request, response): + # This condition avoids false positives from CORS preflight checks, where the + # request under test may be followed immediately by a request to the same URL + # using a different HTTP method. + if b'requireOPTIONS' in request.GET and request.method != b'OPTIONS': + return + + if b'key' in request.GET: + key = request.GET[b'key'] + elif b'key' in request.POST: + key = request.POST[b'key'] + + ## Convert the key from String to UUID valid String ## + testId = hashlib.md5(key).hexdigest() + + ## Handle the header retrieval request ## + if b'retrieve' in request.GET: + recorded_headers = request.server.stash.take(testId) + + if recorded_headers is None: + return (204, [], b'') + + return (200, [], recorded_headers) + + ## Record incoming fetch metadata header value + else: + try: + request.server.stash.put(testId, json.dumps(bytes_to_strings(request.headers))) + except KeyError: + ## The header is already recorded or it doesn't exist + pass + + ## Prevent the browser from caching returned responses and allow CORS ## + response.headers.set(b"Access-Control-Allow-Origin", b"*") + response.headers.set(b"Cache-Control", b"no-cache, no-store, must-revalidate") + response.headers.set(b"Pragma", b"no-cache") + response.headers.set(b"Expires", b"0") + if b"mime" in request.GET: + response.headers.set(b"Content-Type", request.GET.first(b"mime")) + + return request.GET.first(b"body", request.POST.first(b"body", b"")) diff --git a/test/wpt/tests/fetch/metadata/resources/redirectTestHelper.sub.js b/test/wpt/tests/fetch/metadata/resources/redirectTestHelper.sub.js new file mode 100644 index 0000000..1bfbbae --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/redirectTestHelper.sub.js @@ -0,0 +1,167 @@ +function createVideoElement() { + let el = document.createElement('video'); + el.src = '/media/movie_5.mp4'; + el.setAttribute('controls', ''); + el.setAttribute('crossorigin', ''); + return el; +} + +function createTrack() { + let el = document.createElement('track'); + el.setAttribute('default', ''); + el.setAttribute('kind', 'captions'); + el.setAttribute('srclang', 'en'); + return el; +} + +let secureRedirectURL = 'https://{{host}}:{{ports[https][0]}}/fetch/api/resources/redirect.py?location='; +let insecureRedirectURL = 'http://{{host}}:{{ports[http][0]}}/fetch/api/resources/redirect.py?location='; +let secureTestURL = 'https://{{host}}:{{ports[https][0]}}/fetch/metadata/'; +let insecureTestURL = 'http://{{host}}:{{ports[http][0]}}/fetch/metadata/'; + +// Helper to craft an URL that will go from HTTPS => HTTP => HTTPS to +// simulate us downgrading then upgrading again during the same redirect chain. +function MultipleRedirectTo(partialPath) { + let finalURL = insecureRedirectURL + encodeURIComponent(secureTestURL + partialPath); + return secureRedirectURL + encodeURIComponent(finalURL); +} + +// Helper to craft an URL that will go from HTTP => HTTPS to simulate upgrading a +// given request. +function upgradeRedirectTo(partialPath) { + return insecureRedirectURL + encodeURIComponent(secureTestURL + partialPath); +} + +// Helper to craft an URL that will go from HTTPS => HTTP to simulate downgrading a +// given request. +function downgradeRedirectTo(partialPath) { + return secureRedirectURL + encodeURIComponent(insecureTestURL + partialPath); +} + +// Helper to run common redirect test cases that don't require special setup on +// the test page itself. +function RunCommonRedirectTests(testNamePrefix, urlHelperMethod, expectedResults) { + async_test(t => { + let testWindow = window.open(urlHelperMethod('resources/post-to-owner.py?top-level-navigation' + nonce)); + t.add_cleanup(_ => testWindow.close()); + window.addEventListener('message', t.step_func(e => { + if (e.source != testWindow) { + return; + } + + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'document'; + assert_header_equals(e.data, expectation, testNamePrefix + ' top level navigation'); + t.done(); + })); + }, testNamePrefix + ' top level navigation'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'embed-https-redirect' + nonce; + let e = document.createElement('embed'); + e.src = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'embed'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' embed'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' embed'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'object-https-redirect' + nonce; + let e = document.createElement('object'); + e.data = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'object'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' object'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' object'); + + if (document.createElement('link').relList.supports('preload')) { + async_test(t => { + let key = 'preload' + nonce; + let e = document.createElement('link'); + e.rel = 'preload'; + e.href = urlHelperMethod('resources/record-header.py?file=' + key); + e.setAttribute('as', 'track'); + e.onload = e.onerror = t.step_func_done(e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'cors'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(t.step_func(response => response.text())) + .then(t.step_func_done(text => assert_header_equals(text, expectation, testNamePrefix + ' preload'))) + .catch(t.unreached_func()); + }); + document.head.appendChild(e); + }, testNamePrefix + ' preload'); + } + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'style-https-redirect' + nonce; + let e = document.createElement('link'); + e.rel = 'stylesheet'; + e.href = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'no-cors'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'style'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' stylesheet'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' stylesheet'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'track-https-redirect' + nonce; + let video = createVideoElement(); + let el = createTrack(); + el.src = urlHelperMethod('resources/record-header.py?file=' + key); + el.onload = t.step_func(_ => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'cors'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'track'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' track'))) + .then(resolve); + }); + video.appendChild(el); + document.body.appendChild(video); + }); + }, testNamePrefix + ' track'); +} diff --git a/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors-frame.html b/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors-frame.html new file mode 100644 index 0000000..9879802 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors-frame.html @@ -0,0 +1,3 @@ + + +Page Title diff --git a/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors.sw.js b/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors.sw.js new file mode 100644 index 0000000..36c55a7 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/serviceworker-accessors.sw.js @@ -0,0 +1,14 @@ +addEventListener("fetch", event => { + event.waitUntil(async function () { + if (!event.clientId) return; + const client = await clients.get(event.clientId); + if (!client) return; + + client.postMessage({ + "dest": event.request.headers.get("sec-fetch-dest"), + "mode": event.request.headers.get("sec-fetch-mode"), + "site": event.request.headers.get("sec-fetch-site"), + "user": event.request.headers.get("sec-fetch-user") + }); + }()); +}); diff --git a/test/wpt/tests/fetch/metadata/resources/sharedWorker.js b/test/wpt/tests/fetch/metadata/resources/sharedWorker.js new file mode 100644 index 0000000..5eb89cb --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/sharedWorker.js @@ -0,0 +1,9 @@ +onconnect = function(e) { + var port = e.ports[0]; + + port.addEventListener('message', function(e) { + port.postMessage("Ready"); + }); + + port.start(); +} diff --git a/test/wpt/tests/fetch/metadata/resources/unload-with-beacon.html b/test/wpt/tests/fetch/metadata/resources/unload-with-beacon.html new file mode 100644 index 0000000..b00c9a5 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/unload-with-beacon.html @@ -0,0 +1,12 @@ + + diff --git a/test/wpt/tests/fetch/metadata/resources/xslt-test.sub.xml b/test/wpt/tests/fetch/metadata/resources/xslt-test.sub.xml new file mode 100644 index 0000000..acb478a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/resources/xslt-test.sub.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/test/wpt/tests/fetch/metadata/serviceworker-accessors.https.sub.html b/test/wpt/tests/fetch/metadata/serviceworker-accessors.https.sub.html new file mode 100644 index 0000000..03a8321 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/serviceworker-accessors.https.sub.html @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/sharedworker.https.sub.html b/test/wpt/tests/fetch/metadata/sharedworker.https.sub.html new file mode 100644 index 0000000..4df8582 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/sharedworker.https.sub.html @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/style.https.sub.html b/test/wpt/tests/fetch/metadata/style.https.sub.html new file mode 100644 index 0000000..a30d81d --- /dev/null +++ b/test/wpt/tests/fetch/metadata/style.https.sub.html @@ -0,0 +1,86 @@ + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/README.md b/test/wpt/tests/fetch/metadata/tools/README.md new file mode 100644 index 0000000..1c3bac2 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/README.md @@ -0,0 +1,126 @@ +# Fetch Metadata test generation framework + +This directory defines a command-line tool for procedurally generating WPT +tests. + +## Motivation + +Many features of the web platform involve the browser making one or more HTTP +requests to remote servers. Only some aspects of these requests are specified +within the standard that defines the relevant feature. Other aspects are +specified by external standards which span the entire platform (e.g. [Fetch +Metadata Request Headers](https://w3c.github.io/webappsec-fetch-metadata/)). + +This state of affairs makes it difficult to maintain test coverage for two +reasons: + +- When a new feature introduces a new kind of web request, it must be verified + to integrate with every cross-cutting standard. +- When a new cross-cutting standard is introduced, it must be verified to + integrate with every kind of web request. + +The tool in this directory attempts to reduce this tension. It allows +maintainers to express instructions for making web requests in an abstract +sense. These generic instructions can be reused by to produce a different suite +of tests for each cross-cutting feature. + +When a new kind of request is proposed, a single generic template can be +defined here. This will provide the maintainers of all cross-cutting features +with clear instruction on how to extend their test suite with the new feature. + +Similarly, when a new cross-cutting feature is proposed, the authors can use +this tool to build a test suite which spans the entire platform. + +## Build script + +To generate the Fetch Metadata tests, run `./wpt update-built --include fetch` +in the root of the repository. + +## Configuration + +The test generation tool requires a YAML-formatted configuration file as its +input. The file should define a dictionary with the following keys: + +- `templates` - a string describing the filesystem path from which template + files should be loaded +- `output_directory` - a string describing the filesystem path where the + generated test files should be written +- `cases` - a list of dictionaries describing how the test templates should be + expanded with individual subtests; each dictionary should have the following + keys: + - `all_subtests` - properties which should be defined for every expansion + - `common_axis` - a list of dictionaries + - `template_axes` - a dictionary relating template names to properties that + should be used when expanding that particular template + +Internally, the tool creates a set of "subtests" for each template. This set is +the Cartesian product of the `common_axis` and the given template's entry in +the `template_axes` dictionary. It uses this set of subtests to expand the +template, creating an output file. Refer to the next section for a concrete +example of how the expansion is performed. + +In general, the tool will output a single file for each template. However, the +`filename_flags` attribute has special semantics. It is used to separate +subtests for the same template file. This is intended to accommodate [the +web-platform-test's filename-based +conventions](https://web-platform-tests.org/writing-tests/file-names.html). + +For instance, when `.https` is present in a test file's name, the WPT test +harness will load that test using the HTTPS protocol. Subtests which include +the value `https` in the `filename_flags` property will be expanded using the +appropriate template but written to a distinct file whose name includes +`.https`. + +The generation tool requires that the configuration file references every +template in the `templates` directory. Because templates and configuration +files may be contributed by different people, this requirement ensures that +configuration authors are aware of all available templates. Some templates may +not be relevant for some features; in those cases, the configuration file can +include an empty array for the template's entry in the `template_axes` +dictionary (as in `template3.html` in the example which follows). + +## Expansion example + +In the following example configuration file, `a`, `b`, `s`, `w`, `x`, `y`, and +`z` all represent associative arrays. + +```yaml +templates: path/to/templates +output_directory: path/to/output +cases: + - every_subtest: s + common_axis: [a, b] + template_axes: + template1.html: [w] + template2.html: [x, y, z] + template3.html: [] +``` + +When run with such a configuration file, the tool would generate two files, +expanded with data as described below (where `(a, b)` represents the union of +`a` and `b`): + + template1.html: [(a, w), (b, w)] + template2.html: [(a, x), (b, x), (a, y), (b, y), (a, z), (b, z)] + template3.html: (zero tests; not expanded) + +## Design Considerations + +**Efficiency of generated output** The tool is capable of generating a large +number of tests given a small amount of input. Naively structured, this could +result in test suites which take large amount of time and computational +resources to complete. The tool has been designed to help authors structure the +generated output to reduce these resource requirements. + +**Literalness of generated output** Because the generated output is how most +people will interact with the tests, it is important that it be approachable. +This tool avoids outputting abstractions which would frustrate attempts to read +the source code or step through its execution environment. + +**Simplicity** The test generation logic itself was written to be approachable. +This makes it easier to anticipate how the tool will behave with new input, and +it lowers the bar for others to contribute improvements. + +Non-goals include conciseness of template files (verbosity makes the potential +expansions more predictable) and conciseness of generated output (verbosity +aids in the interpretation of results). diff --git a/test/wpt/tests/fetch/metadata/tools/fetch-metadata.conf.yml b/test/wpt/tests/fetch/metadata/tools/fetch-metadata.conf.yml new file mode 100644 index 0000000..b277bcb --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/fetch-metadata.conf.yml @@ -0,0 +1,806 @@ +--- +templates: templates +output_directory: ../generated +cases: + - all_subtests: + expected: NULL + filename_flags: [] + common_axis: + - headerName: sec-fetch-site + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-site + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-site + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-mode + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-mode + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-mode + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-dest + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-dest + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-dest + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-user + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-user + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-user + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + template_axes: + # Unused + appcache-manifest.sub.https.html: [] + # The `audioWorklet` interface is only available in secure contexts + # https://webaudio.github.io/web-audio-api/#BaseAudioContext + audioworklet.https.sub.html: [] + # Service workers are only available in secure context + fetch-via-serviceworker.https.sub.html: [] + # Service workers are only available in secure context + serviceworker.https.sub.html: [] + + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + window-history.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + worker-dedicated-constructor.sub.html: [{}] + + # Sec-Fetch-Site - direct requests + - all_subtests: + headerName: sec-fetch-site + filename_flags: [https] + common_axis: + - description: Same origin + origins: [httpsOrigin] + expected: same-origin + - description: Cross-site + origins: [httpsCrossSite] + expected: cross-site + - description: Same site + origins: [httpsSameSite] + expected: same-site + template_axes: + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + appcache-manifest.sub.https.html: [{}] + audioworklet.https.sub.html: [{}] + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{ init: { mode: no-cors } }] + fetch-via-serviceworker.https.sub.html: [{ init: { mode: no-cors } }] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + serviceworker.https.sub.html: [{}] + svg-image.sub.html: [{}] + window-history.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection from HTTP + - all_subtests: + headerName: sec-fetch-site + filename_flags: [] + common_axis: + - description: HTTPS downgrade (header not sent) + origins: [httpsOrigin, httpOrigin] + expected: NULL + - description: HTTPS upgrade + origins: [httpOrigin, httpsOrigin] + expected: cross-site + - description: HTTPS downgrade-upgrade + origins: [httpsOrigin, httpOrigin, httpsOrigin] + expected: cross-site + template_axes: + # Unused + # The `audioWorklet` interface is only available in secure contexts + # https://webaudio.github.io/web-audio-api/#BaseAudioContext + audioworklet.https.sub.html: [] + # Service workers are only available in secure context + fetch-via-serviceworker.https.sub.html: [] + # Service workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + appcache-manifest.sub.https.html: [{}] + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection from HTTPS + - all_subtests: + headerName: sec-fetch-site + filename_flags: [https] + common_axis: + - description: Same-Origin -> Cross-Site -> Same-Origin redirect + origins: [httpsOrigin, httpsCrossSite, httpsOrigin] + expected: cross-site + - description: Same-Origin -> Same-Site -> Same-Origin redirect + origins: [httpsOrigin, httpsSameSite, httpsOrigin] + expected: same-site + - description: Cross-Site -> Same Origin + origins: [httpsCrossSite, httpsOrigin] + expected: cross-site + - description: Cross-Site -> Same-Site + origins: [httpsCrossSite, httpsSameSite] + expected: cross-site + - description: Cross-Site -> Cross-Site + origins: [httpsCrossSite, httpsCrossSite] + expected: cross-site + - description: Same-Origin -> Same Origin + origins: [httpsOrigin, httpsOrigin] + expected: same-origin + - description: Same-Origin -> Same-Site + origins: [httpsOrigin, httpsSameSite] + expected: same-site + - description: Same-Origin -> Cross-Site + origins: [httpsOrigin, httpsCrossSite] + expected: cross-site + - description: Same-Site -> Same Origin + origins: [httpsSameSite, httpsOrigin] + expected: same-site + - description: Same-Site -> Same-Site + origins: [httpsSameSite, httpsSameSite] + expected: same-site + - description: Same-Site -> Cross-Site + origins: [httpsSameSite, httpsCrossSite] + expected: cross-site + template_axes: + # Service Workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + appcache-manifest.sub.https.html: [{}] + audioworklet.https.sub.html: [{}] + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{ init: { mode: no-cors } }] + fetch-via-serviceworker.https.sub.html: [{ init: { mode: no-cors } }] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection with mixed content + # These tests verify the effect that redirection has on the request's "site". + # The initial request must be made to a resource that is "same-site" with its + # origin. This avoids false positives because if the request were made to a + # cross-site resource, the value of "cross-site" would be assigned regardless + # of the subseqent redirection. + # + # Because these conditions necessarily warrant mixed content, only templates + # which can be configured to allow mixed content [1] can be used. + # + # [1] https://w3c.github.io/webappsec-mixed-content/#should-block-fetch + + - common_axis: + - description: HTTPS downgrade-upgrade + headerName: sec-fetch-site + origins: [httpsOrigin, httpOrigin, httpsOrigin] + expected: cross-site + filename_flags: [https] + template_axes: + # Mixed Content considers only a small subset of requests as + # "optionally-blockable." These are the only requests that can be tested + # for the "downgrade-upgrade" scenario, so all other templates must be + # explicitly ignored. + audioworklet.https.sub.html: [] + css-font-face.sub.html: [] + element-embed.sub.html: [] + element-frame.sub.html: [] + element-iframe.sub.html: [] + element-img-environment-change.sub.html: [] + element-link-icon.sub.html: [] + element-link-prefetch.optional.sub.html: [] + element-picture.sub.html: [] + element-script.sub.html: [] + fetch.sub.html: [] + fetch-via-serviceworker.https.sub.html: [] + header-link.sub.html: [] + script-module-import-static.sub.html: [] + script-module-import-dynamic.sub.html: [] + # Service Workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + worker-dedicated-constructor.sub.html: [] + worker-dedicated-importscripts.sub.html: [] + # Avoid duplicate subtest for 'sec-fetch-site - HTTPS downgrade-upgrade' + appcache-manifest.sub.https.html: [] + css-images.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-img.sub.html: + # srcset omitted because it is not "optionally-blockable" + # https://w3c.github.io/webappsec-mixed-content/#category-optionally-blockable + - sourceAttr: src + element-input-image.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-refresh.optional.sub.html: [{}] + svg-image.sub.html: [{}] + window-location.sub.html: [{}] + + # Sec-Fetch-Mode + # These tests are served over HTTPS so the induced requests will be both + # same-origin with the document [1] and a potentially-trustworthy URL [2]. + # + # [1] https://html.spec.whatwg.org/multipage/origin.html#same-origin + # [2] https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-url + - common_axis: + - headerName: sec-fetch-mode + filename_flags: [https] + origins: [] + template_axes: + appcache-manifest.sub.https.html: + - expected: no-cors + audioworklet.https.sub.html: + # https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-single-module-script + - expected: cors + css-images.sub.html: + - expected: no-cors + filename_flags: [tentative] + css-font-face.sub.html: + - expected: cors + filename_flags: [tentative] + element-a.sub.html: + - expected: navigate + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: no-cors + element-area.sub.html: + - expected: navigate + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: no-cors + element-audio.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-embed.sub.html: + - expected: no-cors + element-frame.sub.html: + - expected: navigate + element-iframe.sub.html: + - expected: navigate + element-img.sub.html: + - sourceAttr: src + expected: no-cors + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: '' } + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: anonymous } + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: use-credentials } + - sourceAttr: srcset + expected: no-cors + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: '' } + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: anonymous } + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: use-credentials } + element-img-environment-change.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-input-image.sub.html: + - expected: no-cors + element-link-icon.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-link-prefetch.optional.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-meta-refresh.optional.sub.html: + - expected: navigate + element-picture.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-script.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { type: module } + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-video.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-video-poster.sub.html: + - expected: no-cors + fetch.sub.html: + - expected: cors + - expected: cors + init: { mode: cors } + - expected: no-cors + init: { mode: no-cors } + - expected: same-origin + init: { mode: same-origin } + fetch-via-serviceworker.https.sub.html: + - expected: cors + - expected: cors + init: { mode: cors } + - expected: no-cors + init: { mode: no-cors } + - expected: same-origin + init: { mode: same-origin } + form-submission.sub.html: + - method: GET + expected: navigate + - method: POST + expected: navigate + header-link.sub.html: + - rel: icon + expected: no-cors + - rel: stylesheet + expected: no-cors + header-refresh.optional.sub.html: + - expected: navigate + window-history.sub.html: + - expected: navigate + window-location.sub.html: + - expected: navigate + script-module-import-dynamic.sub.html: + - expected: cors + script-module-import-static.sub.html: + - expected: cors + # https://svgwg.org/svg2-draft/linking.html#processingURL-fetch + svg-image.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + serviceworker.https.sub.html: + - expected: same-origin + options: { type: 'classic' } + # https://github.com/whatwg/html/pull/5875 + - expected: same-origin + worker-dedicated-constructor.sub.html: + - expected: same-origin + - options: { type: module } + expected: same-origin + worker-dedicated-importscripts.sub.html: + - expected: no-cors + + # Sec-Fetch-Dest + - common_axis: + - headerName: sec-fetch-dest + filename_flags: [https] + origins: [] + template_axes: + appcache-manifest.sub.https.html: + - expected: empty + audioworklet.https.sub.html: + # https://github.com/WebAudio/web-audio-api/issues/2203 + - expected: audioworklet + css-images.sub.html: + - expected: image + filename_flags: [tentative] + css-font-face.sub.html: + - expected: font + filename_flags: [tentative] + element-a.sub.html: + - expected: document + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: empty + element-area.sub.html: + - expected: document + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: empty + element-audio.sub.html: + - expected: audio + element-embed.sub.html: + - expected: embed + element-frame.sub.html: + # https://github.com/whatwg/html/pull/4976 + - expected: frame + element-iframe.sub.html: + # https://github.com/whatwg/html/pull/4976 + - expected: iframe + element-img.sub.html: + - sourceAttr: src + expected: image + - sourceAttr: srcset + expected: image + element-img-environment-change.sub.html: + - expected: image + element-input-image.sub.html: + - expected: image + element-link-icon.sub.html: + - expected: empty + element-link-prefetch.optional.sub.html: + - expected: empty + - elementAttrs: { as: audio } + expected: audio + - elementAttrs: { as: document } + expected: document + - elementAttrs: { as: embed } + expected: embed + - elementAttrs: { as: fetch } + expected: fetch + - elementAttrs: { as: font } + expected: font + - elementAttrs: { as: image } + expected: image + - elementAttrs: { as: object } + expected: object + - elementAttrs: { as: script } + expected: script + - elementAttrs: { as: style } + expected: style + - elementAttrs: { as: track } + expected: track + - elementAttrs: { as: video } + expected: video + - elementAttrs: { as: worker } + expected: worker + element-meta-refresh.optional.sub.html: + - expected: document + element-picture.sub.html: + - expected: image + element-script.sub.html: + - expected: script + element-video.sub.html: + - expected: video + element-video-poster.sub.html: + - expected: image + fetch.sub.html: + - expected: empty + fetch-via-serviceworker.https.sub.html: + - expected: empty + form-submission.sub.html: + - method: GET + expected: document + - method: POST + expected: document + header-link.sub.html: + - rel: icon + expected: empty + - rel: stylesheet + filename_flags: [tentative] + expected: style + header-refresh.optional.sub.html: + - expected: document + window-history.sub.html: + - expected: document + window-location.sub.html: + - expected: document + script-module-import-dynamic.sub.html: + - expected: script + script-module-import-static.sub.html: + - expected: script + serviceworker.https.sub.html: + - expected: serviceworker + # Implemented as "image" in Chromium and Firefox, but specified as + # "empty" + # https://github.com/w3c/svgwg/issues/782 + svg-image.sub.html: + - expected: empty + worker-dedicated-constructor.sub.html: + - expected: worker + - options: { type: module } + expected: worker + worker-dedicated-importscripts.sub.html: + - expected: script + + # Sec-Fetch-User + - common_axis: + - headerName: sec-fetch-user + filename_flags: [https] + origins: [] + template_axes: + appcache-manifest.sub.https.html: + - expected: NULL + audioworklet.https.sub.html: + - expected: NULL + css-images.sub.html: + - expected: NULL + filename_flags: [tentative] + css-font-face.sub.html: + - expected: NULL + filename_flags: [tentative] + element-a.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-area.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-audio.sub.html: + - expected: NULL + element-embed.sub.html: + - expected: NULL + element-frame.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-iframe.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-img.sub.html: + - sourceAttr: src + expected: NULL + - sourceAttr: srcset + expected: NULL + element-img-environment-change.sub.html: + - expected: NULL + element-input-image.sub.html: + - expected: NULL + element-link-icon.sub.html: + - expected: NULL + element-link-prefetch.optional.sub.html: + - expected: NULL + element-meta-refresh.optional.sub.html: + - expected: NULL + element-picture.sub.html: + - expected: NULL + element-script.sub.html: + - expected: NULL + element-video.sub.html: + - expected: NULL + element-video-poster.sub.html: + - expected: NULL + fetch.sub.html: + - expected: NULL + fetch-via-serviceworker.https.sub.html: + - expected: NULL + form-submission.sub.html: + - method: GET + expected: NULL + - method: GET + userActivated: TRUE + expected: ?1 + - method: POST + expected: NULL + - method: POST + userActivated: TRUE + expected: ?1 + header-link.sub.html: + - rel: icon + expected: NULL + - rel: stylesheet + expected: NULL + header-refresh.optional.sub.html: + - expected: NULL + window-history.sub.html: + - expected: NULL + window-location.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + script-module-import-dynamic.sub.html: + - expected: NULL + script-module-import-static.sub.html: + - expected: NULL + serviceworker.https.sub.html: + - expected: NULL + svg-image.sub.html: + - expected: NULL + worker-dedicated-constructor.sub.html: + - expected: NULL + - options: { type: module } + expected: NULL + worker-dedicated-importscripts.sub.html: + - expected: NULL diff --git a/test/wpt/tests/fetch/metadata/tools/generate.py b/test/wpt/tests/fetch/metadata/tools/generate.py new file mode 100644 index 0000000..fa850c8 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/generate.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +import itertools +import os + +import jinja2 +import yaml + +HERE = os.path.abspath(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.join(HERE, '..', '..', '..') + +def find_templates(starting_directory): + for directory, subdirectories, file_names in os.walk(starting_directory): + for file_name in file_names: + if file_name.startswith('.'): + continue + yield file_name, os.path.join(directory, file_name) + +def test_name(directory, template_name, subtest_flags): + ''' + Create a test name based on a template and the WPT file name flags [1] + required for a given subtest. This name is used to determine how subtests + may be grouped together. In order to promote grouping, the combination uses + a few aspects of how file name flags are interpreted: + + - repeated flags have no effect, so duplicates are removed + - flag sequence does not matter, so flags are consistently sorted + + directory | template_name | subtest_flags | result + ----------|------------------|-----------------|------- + cors | image.html | [] | cors/image.html + cors | image.https.html | [] | cors/image.https.html + cors | image.html | [https] | cors/image.https.html + cors | image.https.html | [https] | cors/image.https.html + cors | image.https.html | [https] | cors/image.https.html + cors | image.sub.html | [https] | cors/image.https.sub.html + cors | image.https.html | [sub] | cors/image.https.sub.html + + [1] docs/writing-tests/file-names.md + ''' + template_name_parts = template_name.split('.') + flags = set(subtest_flags) | set(template_name_parts[1:-1]) + test_name_parts = ( + [template_name_parts[0]] + + sorted(flags) + + [template_name_parts[-1]] + ) + return os.path.join(directory, '.'.join(test_name_parts)) + +def merge(a, b): + if type(a) != type(b): + raise Exception('Cannot merge disparate types') + if type(a) == list: + return a + b + if type(a) == dict: + merged = {} + + for key in a: + if key in b: + merged[key] = merge(a[key], b[key]) + else: + merged[key] = a[key] + + for key in b: + if not key in a: + merged[key] = b[key] + + return merged + + raise Exception('Cannot merge {} type'.format(type(a).__name__)) + +def product(a, b): + ''' + Given two lists of objects, compute their Cartesian product by merging the + elements together. For example, + + product( + [{'a': 1}, {'b': 2}], + [{'c': 3}, {'d': 4}, {'e': 5}] + ) + + returns the following list: + + [ + {'a': 1, 'c': 3}, + {'a': 1, 'd': 4}, + {'a': 1, 'e': 5}, + {'b': 2, 'c': 3}, + {'b': 2, 'd': 4}, + {'b': 2, 'e': 5} + ] + ''' + result = [] + + for a_object in a: + for b_object in b: + result.append(merge(a_object, b_object)) + + return result + +def make_provenance(project_root, cases, template): + return '\n'.join([ + 'This test was procedurally generated. Please do not modify it directly.', + 'Sources:', + '- {}'.format(os.path.relpath(cases, project_root)), + '- {}'.format(os.path.relpath(template, project_root)) + ]) + +def collection_filter(obj, title): + if not obj: + return 'no {}'.format(title) + + members = [] + for name, value in obj.items(): + if value == '': + members.append(name) + else: + members.append('{}={}'.format(name, value)) + + return '{}: {}'.format(title, ', '.join(members)) + +def pad_filter(value, side, padding): + if not value: + return '' + if side == 'start': + return padding + value + + return value + padding + +def main(config_file): + with open(config_file, 'r') as handle: + config = yaml.safe_load(handle.read()) + + templates_directory = os.path.normpath( + os.path.join(os.path.dirname(config_file), config['templates']) + ) + + environment = jinja2.Environment( + variable_start_string='[%', + variable_end_string='%]' + ) + environment.filters['collection'] = collection_filter + environment.filters['pad'] = pad_filter + templates = {} + subtests = {} + + for template_name, path in find_templates(templates_directory): + subtests[template_name] = [] + with open(path, 'r') as handle: + templates[template_name] = environment.from_string(handle.read()) + + for case in config['cases']: + unused_templates = set(templates) - set(case['template_axes']) + + # This warning is intended to help authors avoid mistakenly omitting + # templates. It can be silenced by extending the`template_axes` + # dictionary with an empty list for templates which are intentionally + # unused. + if unused_templates: + print( + 'Warning: case does not reference the following templates:' + ) + print('\n'.join('- {}'.format(name) for name in unused_templates)) + + common_axis = product( + case['common_axis'], [case.get('all_subtests', {})] + ) + + for template_name, template_axis in case['template_axes'].items(): + subtests[template_name].extend(product(common_axis, template_axis)) + + for template_name, template in templates.items(): + provenance = make_provenance( + PROJECT_ROOT, + config_file, + os.path.join(templates_directory, template_name) + ) + get_filename = lambda subtest: test_name( + config['output_directory'], + template_name, + subtest['filename_flags'] + ) + subtests_by_filename = itertools.groupby( + sorted(subtests[template_name], key=get_filename), + key=get_filename + ) + for filename, some_subtests in subtests_by_filename: + with open(filename, 'w') as handle: + handle.write(templates[template_name].render( + subtests=list(some_subtests), + provenance=provenance + ) + '\n') + +if __name__ == '__main__': + main('fetch-metadata.conf.yml') diff --git a/test/wpt/tests/fetch/metadata/tools/templates/appcache-manifest.sub.https.html b/test/wpt/tests/fetch/metadata/tools/templates/appcache-manifest.sub.https.html new file mode 100644 index 0000000..0dfc084 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/appcache-manifest.sub.https.html @@ -0,0 +1,63 @@ + + + + + HTTP headers on request for Appcache manifest + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/audioworklet.https.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/audioworklet.https.sub.html new file mode 100644 index 0000000..7be309c --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/audioworklet.https.sub.html @@ -0,0 +1,53 @@ + + + + + HTTP headers on request for AudioWorklet module + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/css-font-face.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/css-font-face.sub.html new file mode 100644 index 0000000..94b33f4 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/css-font-face.sub.html @@ -0,0 +1,60 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/css-images.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/css-images.sub.html new file mode 100644 index 0000000..e394f9f --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/css-images.sub.html @@ -0,0 +1,137 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-a.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-a.sub.html new file mode 100644 index 0000000..2bd8e8a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-a.sub.html @@ -0,0 +1,72 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "a" element navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-area.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-area.sub.html new file mode 100644 index 0000000..0cef5b2 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-area.sub.html @@ -0,0 +1,72 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "area" element navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-audio.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-audio.sub.html new file mode 100644 index 0000000..92bc221 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-audio.sub.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-embed.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-embed.sub.html new file mode 100644 index 0000000..18ce09e --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-embed.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-frame.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-frame.sub.html new file mode 100644 index 0000000..ce90171 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-frame.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-iframe.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-iframe.sub.html new file mode 100644 index 0000000..43a632a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-iframe.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-img-environment-change.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-img-environment-change.sub.html new file mode 100644 index 0000000..5a65114 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-img-environment-change.sub.html @@ -0,0 +1,78 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-img.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-img.sub.html new file mode 100644 index 0000000..1dac584 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-img.sub.html @@ -0,0 +1,52 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-input-image.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-input-image.sub.html new file mode 100644 index 0000000..3c50008 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-input-image.sub.html @@ -0,0 +1,48 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-link-icon.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-link-icon.sub.html new file mode 100644 index 0000000..18ce12a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-link-icon.sub.html @@ -0,0 +1,75 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html new file mode 100644 index 0000000..59d677d --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html @@ -0,0 +1,71 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html new file mode 100644 index 0000000..5a8d8f8 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html @@ -0,0 +1,60 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-picture.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-picture.sub.html new file mode 100644 index 0000000..903aeed --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-picture.sub.html @@ -0,0 +1,101 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-script.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-script.sub.html new file mode 100644 index 0000000..4a281ae --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-script.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-video-poster.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-video-poster.sub.html new file mode 100644 index 0000000..9cdaf06 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-video-poster.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/element-video.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/element-video.sub.html new file mode 100644 index 0000000..1b7b976 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/element-video.sub.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html new file mode 100644 index 0000000..eead710 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html @@ -0,0 +1,88 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request using the "fetch" API and passing through a Serive Worker + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/fetch.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/fetch.sub.html new file mode 100644 index 0000000..a8dc536 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/fetch.sub.html @@ -0,0 +1,42 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/form-submission.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/form-submission.sub.html new file mode 100644 index 0000000..4c9c8c5 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/form-submission.sub.html @@ -0,0 +1,87 @@ + + + + + + HTTP headers on request for HTML form navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/header-link.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/header-link.sub.html new file mode 100644 index 0000000..2831f22 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/header-link.sub.html @@ -0,0 +1,56 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/header-refresh.optional.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/header-refresh.optional.sub.html new file mode 100644 index 0000000..ec963d5 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/header-refresh.optional.sub.html @@ -0,0 +1,59 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html new file mode 100644 index 0000000..653d3cd --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html @@ -0,0 +1,35 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-static.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-static.sub.html new file mode 100644 index 0000000..c8d5f95 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/script-module-import-static.sub.html @@ -0,0 +1,53 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/serviceworker.https.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/serviceworker.https.sub.html new file mode 100644 index 0000000..8284325 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/serviceworker.https.sub.html @@ -0,0 +1,72 @@ + + + + + + HTTP headers on request for Service Workers + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/svg-image.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/svg-image.sub.html new file mode 100644 index 0000000..52f7806 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/svg-image.sub.html @@ -0,0 +1,75 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/window-history.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/window-history.sub.html new file mode 100644 index 0000000..286d019 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/window-history.sub.html @@ -0,0 +1,134 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for navigation via the HTML History API + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/window-location.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/window-location.sub.html new file mode 100644 index 0000000..96f3912 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/window-location.sub.html @@ -0,0 +1,128 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for navigation via the HTML Location API + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html new file mode 100644 index 0000000..fede596 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html @@ -0,0 +1,49 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html b/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html new file mode 100644 index 0000000..93e6374 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/wpt/tests/fetch/metadata/track.https.sub.html b/test/wpt/tests/fetch/metadata/track.https.sub.html new file mode 100644 index 0000000..346798f --- /dev/null +++ b/test/wpt/tests/fetch/metadata/track.https.sub.html @@ -0,0 +1,119 @@ + + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/trailing-dot.https.sub.any.js b/test/wpt/tests/fetch/metadata/trailing-dot.https.sub.any.js new file mode 100644 index 0000000..5e32fc4 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/trailing-dot.https.sub.any.js @@ -0,0 +1,30 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from the same origin, but spelled with a trailing dot."); +}, "Fetching a resource from the same origin, but spelled with a trailing dot."); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from the same site, but spelled with a trailing dot."); +}, "Fetching a resource from the same site, but spelled with a trailing dot."); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from a cross-site host, spelled with a trailing dot."); +}, "Fetching a resource from a cross-site host, spelled with a trailing dot."); diff --git a/test/wpt/tests/fetch/metadata/unload.https.sub.html b/test/wpt/tests/fetch/metadata/unload.https.sub.html new file mode 100644 index 0000000..bc26048 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/unload.https.sub.html @@ -0,0 +1,64 @@ + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/window-open.https.sub.html b/test/wpt/tests/fetch/metadata/window-open.https.sub.html new file mode 100644 index 0000000..94ba76a --- /dev/null +++ b/test/wpt/tests/fetch/metadata/window-open.https.sub.html @@ -0,0 +1,199 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/worker.https.sub.html b/test/wpt/tests/fetch/metadata/worker.https.sub.html new file mode 100644 index 0000000..20a4fe5 --- /dev/null +++ b/test/wpt/tests/fetch/metadata/worker.https.sub.html @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/test/wpt/tests/fetch/metadata/xslt.https.sub.html b/test/wpt/tests/fetch/metadata/xslt.https.sub.html new file mode 100644 index 0000000..dc72d7b --- /dev/null +++ b/test/wpt/tests/fetch/metadata/xslt.https.sub.html @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/test/wpt/tests/fetch/nosniff/image.html b/test/wpt/tests/fetch/nosniff/image.html new file mode 100644 index 0000000..9dfdb94 --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/image.html @@ -0,0 +1,39 @@ + + +
+ diff --git a/test/wpt/tests/fetch/nosniff/importscripts.html b/test/wpt/tests/fetch/nosniff/importscripts.html new file mode 100644 index 0000000..920b6bd --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/importscripts.html @@ -0,0 +1,14 @@ + + +
+ diff --git a/test/wpt/tests/fetch/nosniff/importscripts.js b/test/wpt/tests/fetch/nosniff/importscripts.js new file mode 100644 index 0000000..1895280 --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/importscripts.js @@ -0,0 +1,28 @@ +// Testing importScripts() +function log(w) { this.postMessage(w) } +function f() { log("FAIL") } +function p() { log("PASS") } + +const get_url = (mime, outcome) => { + let url = "resources/js.py" + if (mime != null) { + url += "?type=" + encodeURIComponent(mime) + } + if (outcome) { + url += "&outcome=p" + } + return url +} + +[null, "", "x", "x/x", "text/html", "text/json"].forEach(function(mime) { + try { + importScripts(get_url(mime)) + } catch(e) { + (e.name == "NetworkError") ? p() : log("FAIL (no NetworkError exception): " + mime) + } + +}) +importScripts(get_url("text/javascript", true)) +importScripts(get_url("text/ecmascript", true)) +importScripts(get_url("text/ecmascript;blah", true)) +log("END") diff --git a/test/wpt/tests/fetch/nosniff/parsing-nosniff.window.js b/test/wpt/tests/fetch/nosniff/parsing-nosniff.window.js new file mode 100644 index 0000000..2a26486 --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/parsing-nosniff.window.js @@ -0,0 +1,27 @@ +promise_test(() => fetch("resources/x-content-type-options.json").then(res => res.json()).then(runTests), "Loading JSON…"); + +function runTests(allTestData) { + for (let i = 0; i < allTestData.length; i++) { + const testData = allTestData[i], + input = encodeURIComponent(testData.input); + promise_test(t => { + let resolve; + const promise = new Promise(r => resolve = r); + const script = document.createElement("script"); + t.add_cleanup(() => script.remove()); + // A + +
+ diff --git a/test/wpt/tests/fetch/nosniff/stylesheet.html b/test/wpt/tests/fetch/nosniff/stylesheet.html new file mode 100644 index 0000000..8f2b547 --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/stylesheet.html @@ -0,0 +1,60 @@ + + + +
+ diff --git a/test/wpt/tests/fetch/nosniff/worker.html b/test/wpt/tests/fetch/nosniff/worker.html new file mode 100644 index 0000000..c8c1076 --- /dev/null +++ b/test/wpt/tests/fetch/nosniff/worker.html @@ -0,0 +1,28 @@ + + +
+ diff --git a/test/wpt/tests/fetch/orb/resources/data.json b/test/wpt/tests/fetch/orb/resources/data.json new file mode 100644 index 0000000..f2a886f --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/data.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} diff --git a/test/wpt/tests/fetch/orb/resources/data_non_ascii.json b/test/wpt/tests/fetch/orb/resources/data_non_ascii.json new file mode 100644 index 0000000..64566c5 --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/data_non_ascii.json @@ -0,0 +1 @@ +["你好"] diff --git a/test/wpt/tests/fetch/orb/resources/empty.json b/test/wpt/tests/fetch/orb/resources/empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/empty.json @@ -0,0 +1 @@ +{} diff --git a/test/wpt/tests/fetch/orb/resources/font.ttf b/test/wpt/tests/fetch/orb/resources/font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9023592ef5aa83a03dd6957398897a585062ca57 GIT binary patch literal 2528 zcmds3+iM(E9RAMC>}--IZHkSlbcJb}Hmx+fn~4|=c}SaNsWzb@-3F9mI_^#~3%fJR z?rbg~7_kpEQi_7Nf1zrjK~Q`sQnW2nkwV|M-@%oK-E?>x_(MpM29?acOqAH}eAZX8>i{v90{QD@UK8 z?l;0S4h7BS^-meAn|!xZ@)uj54c;RECHbzRm$TF>nv6e6K2fq3%b3Ch^~cB?u2r(v zkH7ad5c`2P>9SY#cXvOjFn>GsdB|P~`n~De%#NWyu}z}@xPEE<^GzId1b6hq`YrNJ zpl7(G&#mANPHPA{)^6*E!$^@bL|Q0mLqc}TB|Swb8%8pe2%7wX7*!uCHz~QWfyJ-r z7tPW^=Wn!Ro%h$|>{uSdXvUa|I&fOQrS7LPvS9~Z(v&zMA=;65&=C=`DhY|mZ&Kj!3HhbNxDPn<$e@rAG|9>`>_U%Yaa|m@d0+T+9$}A07}*9%}w^Ondom zl3bI=hUcy>--uAx7wLGEX&Kzn_%3s9JFtg_4YL!pi){|FXP{H;ZW!kJ85v$FZVvZc z=7R1t%y+FTKz4K3D>LVrE9j_0Kg^W>kV`b=3OX8csX3V|_H9EhakU|rxWPtGG$xDs zeRLLqoPhkgUkcxKN!R$Lr8Sp8i&%_ketg8+5v}5Y_$i__v?%)`I)-*-GNN_L7dTC! z$#2##gbi9?mv|+j6|{;sB3i|`_#mP+>{8kyItD{YMzl`3g%NltV+j=$Fb4-d3>-ub zhlow2(MK>aNlgJoLYZ6^7Cnmetngdg!aW6>yiIwPzj@l!;1b)kFc{MzW$@m3p1uag z87D`H8(I%iBJ=u;J%|+dLb#J*WgAu=<5fZ*DXp;5R9MY}C{;>IjO(NK5lxbD9RfzY z@=~QR=lI6K+#$nE_oaaWNmxCd;m?tPvxY zJ8xC9c9rx5g?W}XCSRSBcZdsV~Z&>YL1E97mjWcg0TE4dJ(nei;|UEZ=>^4@JlJ9c3=csI~@nRO6C WZTNf5{_GpcUH|0vRERIFfAlvXi@|mP literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/orb/resources/image.png b/test/wpt/tests/fetch/orb/resources/image.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/orb/resources/js-unlabeled-utf16-without-bom.json b/test/wpt/tests/fetch/orb/resources/js-unlabeled-utf16-without-bom.json new file mode 100644 index 0000000000000000000000000000000000000000..157a8f5430862e504c1225de69b998b114f5c289 GIT binary patch literal 70 zcmXSC$YjW4NMXolC}+@P$Y4lhC}xOfNM)!1;$((Wh7us10u(6*@``|J3xFaD47NaA N0_2whWvv;w7y$N*4KDxy literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/orb/resources/js-unlabeled.js b/test/wpt/tests/fetch/orb/resources/js-unlabeled.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/js-unlabeled.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png b/test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png.headers b/test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/png-mislabeled-as-html.png.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/wpt/tests/fetch/orb/resources/png-unlabeled.png b/test/wpt/tests/fetch/orb/resources/png-unlabeled.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/wpt/tests/fetch/orb/resources/script-asm-js-invalid.js b/test/wpt/tests/fetch/orb/resources/script-asm-js-invalid.js new file mode 100644 index 0000000..8d1bbd6 --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/script-asm-js-invalid.js @@ -0,0 +1,4 @@ +function f() { + "use asm"; + return; +} diff --git a/test/wpt/tests/fetch/orb/resources/script-asm-js-valid.js b/test/wpt/tests/fetch/orb/resources/script-asm-js-valid.js new file mode 100644 index 0000000..79b375f --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/script-asm-js-valid.js @@ -0,0 +1,4 @@ +function f() { + "use asm"; + return {}; +} diff --git a/test/wpt/tests/fetch/orb/resources/script-iso-8559-1.js b/test/wpt/tests/fetch/orb/resources/script-iso-8559-1.js new file mode 100644 index 0000000..3bccb6a --- /dev/null +++ b/test/wpt/tests/fetch/orb/resources/script-iso-8559-1.js @@ -0,0 +1,4 @@ +"use strict"; +function fn() { + return "§A¦n"; +} diff --git a/test/wpt/tests/fetch/orb/resources/script-utf16-bom.js b/test/wpt/tests/fetch/orb/resources/script-utf16-bom.js new file mode 100644 index 0000000000000000000000000000000000000000..16b76e9d5e431fd9c363b0b1a15ba095e2a9fa11 GIT binary patch literal 92 zcmezWPl=(Fp_n0+K>eWS}F>3_u(hP=PTR-<`~R#tG*A|1EHYf%yOf;}RfO ufq}uKfq{X=$I;i-SkKZ@&oq=;0A!D5GnzfrG91YqkUhcZ{y~zb783yc + + +
+ + diff --git a/test/wpt/tests/fetch/orb/tentative/content-range.sub.any.js b/test/wpt/tests/fetch/orb/tentative/content-range.sub.any.js new file mode 100644 index 0000000..ee97521 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/content-range.sub.any.js @@ -0,0 +1,31 @@ +// META: script=/fetch/orb/resources/utils.js + +const url = + "http://{{domains[www1]}}:{{ports[http][0]}}/fetch/orb/resources/image.png"; + +promise_test(async () => { + let headers = new Headers([["Range", "bytes=0-99"]]); + await fetchORB( + url, + { headers }, + header("Content-Range", "bytes 0-99/1010"), + "slice(null,100)", + "status(206)" + ); +}, "ORB shouldn't block opaque range of image/png starting at zero"); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + url, + { headers: new Headers([["Range", "bytes 10-99"]]) }, + header("Content-Range", "bytes 10-99/1010"), + "slice(10,100)", + "status(206)" + ) + ), + "ORB should block opaque range of image/png not starting at zero, that isn't subsequent" +); diff --git a/test/wpt/tests/fetch/orb/tentative/img-mime-types-coverage.tentative.sub.html b/test/wpt/tests/fetch/orb/tentative/img-mime-types-coverage.tentative.sub.html new file mode 100644 index 0000000..5dc6c5d --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/img-mime-types-coverage.tentative.sub.html @@ -0,0 +1,126 @@ + + + +
+ + diff --git a/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub-ref.html b/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub-ref.html new file mode 100644 index 0000000..66462fb --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub-ref.html @@ -0,0 +1,5 @@ + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub.html b/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub.html new file mode 100644 index 0000000..aa03f4d --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/img-png-mislabeled-as-html.sub.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub-ref.html b/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub-ref.html new file mode 100644 index 0000000..2d5e3bb --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub-ref.html @@ -0,0 +1,5 @@ + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub.html b/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub.html new file mode 100644 index 0000000..77415f6 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/img-png-unlabeled.sub.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/known-mime-type.sub.any.js b/test/wpt/tests/fetch/orb/tentative/known-mime-type.sub.any.js new file mode 100644 index 0000000..b0521e8 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/known-mime-type.sub.any.js @@ -0,0 +1,86 @@ +// META: script=/fetch/orb/resources/utils.js + +const path = "http://{{domains[www1]}}:{{ports[http][0]}}/fetch/orb/resources"; + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB(`${path}/font.ttf`, null, contentType("font/ttf")) + ), + "ORB should block opaque font/ttf" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB(`${path}/text.txt`, null, contentType("text/plain")) + ), + "ORB should block opaque text/plain" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB(`${path}/data.json`, null, contentType("application/json")) + ), + "ORB should block opaque application/json (non-empty)" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB(`${path}/empty.json`, null, contentType("application/json")) + ), + "ORB should block opaque application/json (empty)" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB(`${path}/data_non_ascii.json`, null, contentType("application/json")) + ), + "ORB should block opaque application/json which contains non ascii characters" +); + +promise_test(async () => { + fetchORB(`${path}/image.png`, null, contentType("image/png")); +}, "ORB shouldn't block opaque image/png"); + +promise_test(async () => { + await fetchORB(`${path}/script.js`, null, contentType("text/javascript")); +}, "ORB shouldn't block opaque text/javascript"); + +// Test javascript validation can correctly decode the content with BOM. +promise_test(async () => { + await fetchORB(`${path}/script-utf16-bom.js`, null, contentType("application/json")); +}, "ORB shouldn't block opaque text/javascript (utf16 encoded with BOM)"); + +// Test javascript validation can correctly decode the content with the http charset hint. +promise_test(async () => { + await fetchORB(`${path}/script-utf16-without-bom.js`, null, contentType("application/json; charset=utf-16")); +}, "ORB shouldn't block opaque text/javascript (utf16 encoded without BOM but charset is provided in content-type)"); + +// Test javascript validation can correctly decode the content for iso-8559-1 (fallback decoder in Firefox). +promise_test(async () => { + await fetchORB(`${path}/script-iso-8559-1.js`, null, contentType("application/json")); +}, "ORB shouldn't block opaque text/javascript (iso-8559-1 encoded)"); + +// Test javascript validation can correctly parse asm.js. +promise_test(async () => { + await fetchORB(`${path}/script-asm-js-valid.js`, null, contentType("application/json")); +}, "ORB shouldn't block text/javascript with valid asm.js"); + +// Test javascript validation can correctly parse invalid asm.js with valid JS syntax. +promise_test(async () => { + await fetchORB(`${path}/script-asm-js-invalid.js`, null, contentType("application/json")); +}, "ORB shouldn't block text/javascript with invalid asm.js"); diff --git a/test/wpt/tests/fetch/orb/tentative/nosniff.sub.any.js b/test/wpt/tests/fetch/orb/tentative/nosniff.sub.any.js new file mode 100644 index 0000000..3df9d22 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/nosniff.sub.any.js @@ -0,0 +1,59 @@ +// META: script=/fetch/orb/resources/utils.js + +const path = "http://{{domains[www1]}}:{{ports[http][0]}}/fetch/orb/resources"; + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + `${path}/text.txt`, + null, + contentType("text/plain"), + contentTypeOptions("nosniff") + ) + ), + "ORB should block opaque text/plain with nosniff" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + `${path}/data.json`, + null, + contentType("application/json"), + contentTypeOptions("nosniff") + ) + ), + "ORB should block opaque-response-blocklisted MIME type with nosniff" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + `${path}/data.json`, + null, + contentType(""), + contentTypeOptions("nosniff") + ) + ), + "ORB should block opaque response with empty Content-Type and nosniff" +); + +promise_test( + () => + fetchORB( + `${path}/image.png`, + null, + contentType(""), + contentTypeOptions("nosniff") + ), + "ORB shouldn't block opaque image with empty Content-Type and nosniff" +); diff --git a/test/wpt/tests/fetch/orb/tentative/script-js-unlabeled-gziped.sub.html b/test/wpt/tests/fetch/orb/tentative/script-js-unlabeled-gziped.sub.html new file mode 100644 index 0000000..fe85440 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/script-js-unlabeled-gziped.sub.html @@ -0,0 +1,24 @@ + + + + + +
+ + + + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/script-unlabeled.sub.html b/test/wpt/tests/fetch/orb/tentative/script-unlabeled.sub.html new file mode 100644 index 0000000..4987f13 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/script-unlabeled.sub.html @@ -0,0 +1,24 @@ + + + + + +
+ + + + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/script-utf16-without-bom-hint-charset.sub.html b/test/wpt/tests/fetch/orb/tentative/script-utf16-without-bom-hint-charset.sub.html new file mode 100644 index 0000000..b15f976 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/script-utf16-without-bom-hint-charset.sub.html @@ -0,0 +1,22 @@ + + + + +
+ + + + + + + diff --git a/test/wpt/tests/fetch/orb/tentative/status.sub.any.js b/test/wpt/tests/fetch/orb/tentative/status.sub.any.js new file mode 100644 index 0000000..b94d8b7 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/status.sub.any.js @@ -0,0 +1,33 @@ +// META: script=/fetch/orb/resources/utils.js + +const path = "http://{{domains[www1]}}:{{ports[http][0]}}/fetch/orb/resources"; + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + `${path}/data.json`, + null, + contentType("application/json"), + "status(206)" + ) + ), + "ORB should block opaque-response-blocklisted MIME type with status 206" +); + +promise_test( + t => + promise_rejects_js( + t, + TypeError, + fetchORB( + `${path}/data.json`, + null, + contentType("application/json"), + "status(302)" + ) + ), + "ORB should block opaque response with non-ok status" +); diff --git a/test/wpt/tests/fetch/orb/tentative/status.sub.html b/test/wpt/tests/fetch/orb/tentative/status.sub.html new file mode 100644 index 0000000..a62bdeb --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/status.sub.html @@ -0,0 +1,17 @@ +'use strict'; + + + +
+ diff --git a/test/wpt/tests/fetch/orb/tentative/unknown-mime-type.sub.any.js b/test/wpt/tests/fetch/orb/tentative/unknown-mime-type.sub.any.js new file mode 100644 index 0000000..f72ff92 --- /dev/null +++ b/test/wpt/tests/fetch/orb/tentative/unknown-mime-type.sub.any.js @@ -0,0 +1,28 @@ +// META: script=/fetch/orb/resources/utils.js + +const path = "http://{{domains[www1]}}:{{ports[http][0]}}/fetch/orb/resources"; + +promise_test( + () => fetchORB(`${path}/font.ttf`, null, contentType("")), + "ORB shouldn't block opaque failed missing MIME type (font/ttf)" +); + +promise_test( + () => fetchORB(`${path}/text.txt`, null, contentType("")), + "ORB shouldn't block opaque failed missing MIME type (text/plain)" +); + +promise_test( + t => fetchORB(`${path}/data.json`, null, contentType("")), + "ORB shouldn't block opaque failed missing MIME type (application/json)" +); + +promise_test( + () => fetchORB(`${path}/image.png`, null, contentType("")), + "ORB shouldn't block opaque failed missing MIME type (image/png)" +); + +promise_test( + () => fetchORB(`${path}/script.js`, null, contentType("")), + "ORB shouldn't block opaque failed missing MIME type (text/javascript)" +); diff --git a/test/wpt/tests/fetch/origin/assorted.window.js b/test/wpt/tests/fetch/origin/assorted.window.js new file mode 100644 index 0000000..033d010 --- /dev/null +++ b/test/wpt/tests/fetch/origin/assorted.window.js @@ -0,0 +1,211 @@ +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +const origins = get_host_info(); + +promise_test(async function () { + const stash = token(), + redirectPath = "/fetch/origin/resources/redirect-and-stash.py"; + + // Cross-origin -> same-origin will result in setting the tainted origin flag for the second + // request. + let url = origins.HTTP_ORIGIN + redirectPath + "?stash=" + stash; + url = origins.HTTP_REMOTE_ORIGIN + redirectPath + "?stash=" + stash + "&location=" + encodeURIComponent(url) + "&dummyJS"; + + await fetch(url, { mode: "no-cors", method: "POST" }); + + const json = await (await fetch(redirectPath + "?dump&stash=" + stash)).json(); + + assert_equals(json[0], origins.HTTP_ORIGIN); + assert_equals(json[1], "null"); +}, "Origin header and 308 redirect"); + +promise_test(async function () { + const stash = token(), + redirectPath = "/fetch/origin/resources/redirect-and-stash.py"; + + let url = origins.HTTP_ORIGIN + redirectPath + "?stash=" + stash; + url = origins.HTTP_REMOTE_ORIGIN + redirectPath + "?stash=" + stash + "&location=" + encodeURIComponent(url); + + await new Promise(resolve => { + const frame = document.createElement("iframe"); + frame.src = url; + frame.onload = () => { + resolve(); + frame.remove(); + } + document.body.appendChild(frame); + }); + + const json = await (await fetch(redirectPath + "?dump&stash=" + stash)).json(); + + assert_equals(json[0], "no Origin header"); + assert_equals(json[1], "no Origin header"); +}, "Origin header and GET navigation"); + +promise_test(async function () { + const stash = token(), + redirectPath = "/fetch/origin/resources/redirect-and-stash.py"; + + let url = origins.HTTP_ORIGIN + redirectPath + "?stash=" + stash; + url = origins.HTTP_REMOTE_ORIGIN + redirectPath + "?stash=" + stash + "&location=" + encodeURIComponent(url); + + await new Promise(resolve => { + const frame = document.createElement("iframe"); + self.addEventListener("message", e => { + if (e.data === "loaded") { + resolve(); + frame.remove(); + } + }, { once: true }); + frame.onload = () => { + const doc = frame.contentDocument, + form = doc.body.appendChild(doc.createElement("form")), + submit = form.appendChild(doc.createElement("input")); + form.action = url; + form.method = "POST"; + submit.type = "submit"; + submit.click(); + } + document.body.appendChild(frame); + }); + + const json = await (await fetch(redirectPath + "?dump&stash=" + stash)).json(); + + assert_equals(json[0], origins.HTTP_ORIGIN); + assert_equals(json[1], "null"); +}, "Origin header and POST navigation"); + +function navigationReferrerPolicy(referrerPolicy, destination, expectedOrigin) { + return async function () { + const stash = token(); + const referrerPolicyPath = "/fetch/origin/resources/referrer-policy.py"; + const redirectPath = "/fetch/origin/resources/redirect-and-stash.py"; + + let postUrl = + (destination === "same-origin" ? origins.HTTP_ORIGIN + : origins.HTTP_REMOTE_ORIGIN) + + redirectPath + "?stash=" + stash; + + await new Promise(resolve => { + const frame = document.createElement("iframe"); + document.body.appendChild(frame); + frame.src = origins.HTTP_ORIGIN + referrerPolicyPath + + "?referrerPolicy=" + referrerPolicy; + self.addEventListener("message", function listener(e) { + if (e.data === "loaded") { + resolve(); + frame.remove(); + self.removeEventListener("message", listener); + } else if (e.data === "action") { + const doc = frame.contentDocument, + form = doc.body.appendChild(doc.createElement("form")), + submit = form.appendChild(doc.createElement("input")); + form.action = postUrl; + form.method = "POST"; + submit.type = "submit"; + submit.click(); + } + }); + }); + + const json = await (await fetch(redirectPath + "?dump&stash=" + stash)).json(); + + assert_equals(json[0], expectedOrigin); + }; +} + +function fetchReferrerPolicy(referrerPolicy, destination, fetchMode, expectedOrigin, httpMethod) { + return async function () { + const stash = token(); + const redirectPath = "/fetch/origin/resources/redirect-and-stash.py"; + + let fetchUrl = + (destination === "same-origin" ? origins.HTTP_ORIGIN + : origins.HTTP_REMOTE_ORIGIN) + + redirectPath + "?stash=" + stash + "&dummyJS"; + + await fetch(fetchUrl, { mode: fetchMode, method: httpMethod , "referrerPolicy": referrerPolicy}); + + const json = await (await fetch(redirectPath + "?dump&stash=" + stash)).json(); + + assert_equals(json[0], expectedOrigin); + }; +} + +function referrerPolicyTestString(referrerPolicy, method, destination) { + return "Origin header and " + method + " " + destination + " with Referrer-Policy " + + referrerPolicy; +} + +[ + { + "policy": "no-referrer", + "expectedOriginForSameOrigin": "null", + "expectedOriginForCrossOrigin": "null" + }, + { + "policy": "same-origin", + "expectedOriginForSameOrigin": origins.HTTP_ORIGIN, + "expectedOriginForCrossOrigin": "null" + }, + { + "policy": "origin-when-cross-origin", + "expectedOriginForSameOrigin": origins.HTTP_ORIGIN, + "expectedOriginForCrossOrigin": origins.HTTP_ORIGIN + }, + { + "policy": "no-referrer-when-downgrade", + "expectedOriginForSameOrigin": origins.HTTP_ORIGIN, + "expectedOriginForCrossOrigin": origins.HTTP_ORIGIN + }, + { + "policy": "unsafe-url", + "expectedOriginForSameOrigin": origins.HTTP_ORIGIN, + "expectedOriginForCrossOrigin": origins.HTTP_ORIGIN + }, +].forEach(testObj => { + [ + { + "name": "same-origin", + "expectedOrigin": testObj.expectedOriginForSameOrigin + }, + { + "name": "cross-origin", + "expectedOrigin": testObj.expectedOriginForCrossOrigin + } + ].forEach(destination => { + // Test form POST navigation + promise_test(navigationReferrerPolicy(testObj.policy, + destination.name, + destination.expectedOrigin), + referrerPolicyTestString(testObj.policy, "POST", + destination.name + " navigation")); + // Test fetch + promise_test(fetchReferrerPolicy(testObj.policy, + destination.name, + "no-cors", + destination.expectedOrigin, + "POST"), + referrerPolicyTestString(testObj.policy, "POST", + destination.name + " fetch no-cors mode")); + + // Test cors mode POST + promise_test(fetchReferrerPolicy(testObj.policy, + destination.name, + "cors", + origins.HTTP_ORIGIN, + "POST"), + referrerPolicyTestString(testObj.policy, "POST", + destination.name + " fetch cors mode")); + + // Test cors mode GET + promise_test(fetchReferrerPolicy(testObj.policy, + destination.name, + "cors", + (destination.name == "same-origin") ? "no Origin header" : origins.HTTP_ORIGIN, + "GET"), + referrerPolicyTestString(testObj.policy, "GET", + destination.name + " fetch cors mode")); + }); +}); diff --git a/test/wpt/tests/fetch/origin/resources/redirect-and-stash.py b/test/wpt/tests/fetch/origin/resources/redirect-and-stash.py new file mode 100644 index 0000000..36c584c --- /dev/null +++ b/test/wpt/tests/fetch/origin/resources/redirect-and-stash.py @@ -0,0 +1,38 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + key = request.GET.first(b"stash") + origin = request.headers.get(b"origin") + if origin is None: + origin = b"no Origin header" + + origin_list = request.server.stash.take(key) + + if b"dump" in request.GET: + response.headers.set(b"Content-Type", b"application/json") + response.content = json.dumps(origin_list) + return + + if origin_list is None: + origin_list = [isomorphic_decode(origin)] + else: + origin_list.append(isomorphic_decode(origin)) + + request.server.stash.put(key, origin_list) + + if b"location" in request.GET: + location = request.GET.first(b"location") + if b"dummyJS" in request.GET: + location += b"&dummyJS" + response.status = 308 + response.headers.set(b"Location", location) + return + + response.headers.set(b"Content-Type", b"text/html") + response.headers.set(b"Access-Control-Allow-Origin", b"*") + if b"dummyJS" in request.GET: + response.content = b"console.log('dummy JS')" + else: + response.content = b"\n" diff --git a/test/wpt/tests/fetch/origin/resources/referrer-policy.py b/test/wpt/tests/fetch/origin/resources/referrer-policy.py new file mode 100644 index 0000000..15716e0 --- /dev/null +++ b/test/wpt/tests/fetch/origin/resources/referrer-policy.py @@ -0,0 +1,7 @@ +def main(request, response): + if b"referrerPolicy" in request.GET: + response.headers.set(b"Referrer-Policy", + request.GET.first(b"referrerPolicy")) + response.status = 200 + response.headers.set(b"Content-Type", b"text/html") + response.content = b"\n" diff --git a/test/wpt/tests/fetch/private-network-access/META.yml b/test/wpt/tests/fetch/private-network-access/META.yml new file mode 100644 index 0000000..944ce6f --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/META.yml @@ -0,0 +1,7 @@ +spec: https://wicg.github.io/private-network-access/ +suggested_reviewers: + - letitz + - lyf + - hemeryar + - camillelamy + - mikewest diff --git a/test/wpt/tests/fetch/private-network-access/README.md b/test/wpt/tests/fetch/private-network-access/README.md new file mode 100644 index 0000000..a69aab4 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/README.md @@ -0,0 +1,10 @@ +# Private Network Access tests + +This directory contains tests for Private Network Access' integration with +the Fetch specification. + +See also: + +* [The specification](https://wicg.github.io/private-network-access/) +* [The repository](https://github.com/WICG/private-network-access/) +* [Open issues](https://github.com/WICG/private-network-access/issues/) diff --git a/test/wpt/tests/fetch/private-network-access/fenced-frame-no-preflight-required.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/fenced-frame-no-preflight-required.tentative.https.window.js new file mode 100644 index 0000000..21233f6 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fenced-frame-no-preflight-required.tentative.https.window.js @@ -0,0 +1,91 @@ +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: script=/fenced-frame/resources/utils.js +// META: timeout=long +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that contexts can navigate fenced frames to more-public or +// same address spaces without private network access preflight request header. + +setup(() => { + assert_true(window.isSecureContext); +}); + +// Source: secure local context. +// +// All fetches unaffected by Private Network Access. + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: {server: Server.HTTPS_LOCAL}, + expected: FrameTestResult.SUCCESS, + }), + 'local to local: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: {server: Server.HTTPS_PRIVATE}, + expected: FrameTestResult.SUCCESS, + }), + 'local to private: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: {server: Server.HTTPS_PUBLIC}, + expected: FrameTestResult.SUCCESS, + }), + 'local to public: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_PRIVATE}, + target: {server: Server.HTTPS_PRIVATE}, + expected: FrameTestResult.SUCCESS, + }), + 'private to private: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_PRIVATE}, + target: {server: Server.HTTPS_PUBLIC}, + expected: FrameTestResult.SUCCESS, + }), + 'private to public: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: {server: Server.HTTPS_PUBLIC}, + target: {server: Server.HTTPS_PUBLIC}, + expected: FrameTestResult.SUCCESS, + }), + 'public to public: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: {server: Server.HTTPS_PUBLIC}, + expected: FrameTestResult.SUCCESS, + }), + 'treat-as-public-address to public: no preflight required.'); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: {preflight: PreflightBehavior.optionalSuccess(token())} + }, + expected: FrameTestResult.SUCCESS, + }), + 'treat-as-public-address to local: optional preflight'); diff --git a/test/wpt/tests/fetch/private-network-access/fenced-frame-subresource-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/fenced-frame-subresource-fetch.tentative.https.window.js new file mode 100644 index 0000000..2dff325 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fenced-frame-subresource-fetch.tentative.https.window.js @@ -0,0 +1,330 @@ +// META: script=/common/subset-tests-by-key.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: script=/fenced-frame/resources/utils.js +// META: variant=?include=baseline +// META: variant=?include=from-local +// META: variant=?include=from-private +// META: variant=?include=from-public +// META: timeout=long +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that secure contexts can fetch subresources in fenced +// frames from all address spaces, provided that the target server, if more +// private than the initiator, respond affirmatively to preflight requests. +// + +setup(() => { + // Making sure we are in a secure context, as expected. + assert_true(window.isSecureContext); +}); + +// Source: secure local context. +// +// All fetches unaffected by Private Network Access. + +subsetTestByKey( + 'from-local', promise_test, t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: {server: Server.HTTPS_LOCAL}, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'local to local: no preflight required.'); + +subsetTestByKey( + 'from-local', promise_test, + t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: { + server: Server.HTTPS_PRIVATE, + behavior: {response: ResponseBehavior.allowCrossOrigin()}, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'local to private: no preflight required.'); + + +subsetTestByKey( + 'from-local', promise_test, + t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: { + server: Server.HTTPS_PUBLIC, + behavior: {response: ResponseBehavior.allowCrossOrigin()}, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'local to public: no preflight required.'); + +// Strictly speaking, the following two tests do not exercise PNA-specific +// logic, but they serve as a baseline for comparison, ensuring that non-PNA +// preflight requests are sent and handled as expected. + +subsetTestByKey( + 'baseline', promise_test, + t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { + preflight: PreflightBehavior.failure(), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'PUT', mode: 'cors'}, + expected: FetchTestResult.FAILURE, + }), + 'local to public: PUT preflight failure.'); + +subsetTestByKey( + 'baseline', promise_test, + t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_LOCAL}, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + } + }, + fetchOptions: {method: 'PUT', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'local to public: PUT preflight success.'); + +// Generates tests of preflight behavior for a single (source, target) pair. +// +// Scenarios: +// +// - cors mode: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - final response is missing CORS headers +// - success +// - success with PUT method (non-"simple" request) +// - no-cors mode: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - success +// +function makePreflightTests({ + subsetKey, + source, + sourceDescription, + targetServer, + targetDescription, +}) { + const prefix = `${sourceDescription} to ${targetDescription}: `; + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.failure(), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'failed preflight.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.noCorsHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'missing CORS headers on preflight response.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'missing PNA header on preflight response.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.success(token())}, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'missing CORS headers on final response.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + prefix + 'success.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: {method: 'PUT', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + prefix + 'PUT success.'); + + subsetTestByKey( + subsetKey, promise_test, t => fencedFrameFetchTest(t, { + source, + target: {server: targetServer}, + fetchOptions: {method: 'GET', mode: 'no-cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'no-CORS mode failed preflight.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.noCorsHeader(token())}, + }, + fetchOptions: {method: 'GET', mode: 'no-cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'no-CORS mode missing CORS headers on preflight response.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.noPnaHeader(token())}, + }, + fetchOptions: {method: 'GET', mode: 'no-cors'}, + expected: FetchTestResult.FAILURE, + }), + prefix + 'no-CORS mode missing PNA header on preflight response.'); + + subsetTestByKey( + subsetKey, promise_test, + t => fencedFrameFetchTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.success(token())}, + }, + fetchOptions: {method: 'GET', mode: 'no-cors'}, + expected: FetchTestResult.OPAQUE, + }), + prefix + 'no-CORS mode success.'); +} + +// Source: private secure context. +// +// Fetches to the local address space require a successful preflight response +// carrying a PNA-specific header. + +makePreflightTests({ + subsetKey: 'from-private', + source: {server: Server.HTTPS_PRIVATE}, + sourceDescription: 'private', + targetServer: Server.HTTPS_LOCAL, + targetDescription: 'local', +}); + +subsetTestByKey( + 'from-private', promise_test, t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_PRIVATE}, + target: {server: Server.HTTPS_PRIVATE}, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'private to private: no preflight required.'); + +subsetTestByKey( + 'from-private', promise_test, + t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_PRIVATE}, + target: { + server: Server.HTTPS_PRIVATE, + behavior: {response: ResponseBehavior.allowCrossOrigin()}, + }, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'private to public: no preflight required.'); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require a successful +// preflight response carrying a PNA-specific header. + +makePreflightTests({ + subsetKey: 'from-public', + source: {server: Server.HTTPS_PUBLIC}, + sourceDescription: 'public', + targetServer: Server.HTTPS_LOCAL, + targetDescription: 'local', +}); + +makePreflightTests({ + subsetKey: 'from-public', + source: {server: Server.HTTPS_PUBLIC}, + sourceDescription: 'public', + targetServer: Server.HTTPS_PRIVATE, + targetDescription: 'private', +}); + +subsetTestByKey( + 'from-public', promise_test, t => fencedFrameFetchTest(t, { + source: {server: Server.HTTPS_PUBLIC}, + target: {server: Server.HTTPS_PUBLIC}, + fetchOptions: {method: 'GET', mode: 'cors'}, + expected: FetchTestResult.SUCCESS, + }), + 'public to public: no preflight required.'); diff --git a/test/wpt/tests/fetch/private-network-access/fenced-frame.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/fenced-frame.tentative.https.window.js new file mode 100644 index 0000000..370cc9f --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fenced-frame.tentative.https.window.js @@ -0,0 +1,150 @@ +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: script=/fenced-frame/resources/utils.js +// META: timeout=long +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that contexts can navigate fenced frames to less-public +// address spaces iff the target server responds affirmatively to preflight +// requests. + +setup(() => { + assert_true(window.isSecureContext); +}); + +// Generates tests of preflight behavior for a single (source, target) pair. +// +// Scenarios: +// +// - parent navigates child: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - preflight response has the required PNA related headers, but still fails +// because of the limitation of fenced frame that subjects to PNA checks. +// +function makePreflightTests({ + sourceName, + sourceServer, + sourceTreatAsPublic, + targetName, + targetServer, +}) { + const prefix = `${sourceName} to ${targetName}: `; + + const source = { + server: sourceServer, + treatAsPublic: sourceTreatAsPublic, + }; + + promise_test_parallel( + t => fencedFrameTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.failure()}, + }, + expected: FrameTestResult.FAILURE, + }), + prefix + 'failed preflight.'); + + promise_test_parallel( + t => fencedFrameTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.noCorsHeader(token())}, + }, + expected: FrameTestResult.FAILURE, + }), + prefix + 'missing CORS headers.'); + + promise_test_parallel( + t => fencedFrameTest(t, { + source, + target: { + server: targetServer, + behavior: {preflight: PreflightBehavior.noPnaHeader(token())}, + }, + expected: FrameTestResult.FAILURE, + }), + prefix + 'missing PNA header.'); + + promise_test_parallel( + t => fencedFrameTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin() + }, + }, + expected: FrameTestResult.FAILURE, + }), + prefix + 'failed because fenced frames are incompatible with PNA.'); +} + +// Source: private secure context. +// +// Fetches to the local address space require a successful preflight response +// carrying a PNA-specific header. + +makePreflightTests({ + sourceServer: Server.HTTPS_PRIVATE, + sourceName: 'private', + targetServer: Server.HTTPS_LOCAL, + targetName: 'local', +}); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require a successful +// preflight response carrying a PNA-specific header. + +makePreflightTests({ + sourceServer: Server.HTTPS_PUBLIC, + sourceName: 'public', + targetServer: Server.HTTPS_LOCAL, + targetName: 'local', +}); + +makePreflightTests({ + sourceServer: Server.HTTPS_PUBLIC, + sourceName: 'public', + targetServer: Server.HTTPS_PRIVATE, + targetName: 'private', +}); + +// The following tests verify that `CSP: treat-as-public-address` makes +// documents behave as if they had been served from a public IP address. + +makePreflightTests({ + sourceServer: Server.HTTPS_LOCAL, + sourceTreatAsPublic: true, + sourceName: 'treat-as-public-address', + targetServer: Server.OTHER_HTTPS_LOCAL, + targetName: 'local', +}); + +promise_test_parallel( + t => fencedFrameTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: {server: Server.HTTPS_LOCAL}, + expected: FrameTestResult.FAILURE, + }), + 'treat-as-public-address to local (same-origin): fenced frame embedder ' + + 'initiated navigation has opaque origin.'); + +makePreflightTests({ + sourceServer: Server.HTTPS_LOCAL, + sourceTreatAsPublic: true, + sourceName: 'treat-as-public-address', + targetServer: Server.HTTPS_PRIVATE, + targetName: 'private', +}); diff --git a/test/wpt/tests/fetch/private-network-access/fetch-from-treat-as-public.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/fetch-from-treat-as-public.tentative.https.window.js new file mode 100644 index 0000000..084e032 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fetch-from-treat-as-public.tentative.https.window.js @@ -0,0 +1,80 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that documents fetched from the `local` or `private` +// address space yet carrying the `treat-as-public-address` CSP directive are +// treated as if they had been fetched from the `public` address space. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + preflight: PreflightBehavior.noPnaHeader(token()), + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public-address to local: failed preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public-address to local: success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public-address to local (same-origin): no preflight required."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_PRIVATE }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public-address to private: failed preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public-address to private: success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public-address to public: no preflight required."); diff --git a/test/wpt/tests/fetch/private-network-access/fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/fetch.tentative.https.window.js new file mode 100644 index 0000000..dbc4f23 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fetch.tentative.https.window.js @@ -0,0 +1,271 @@ +// META: script=/common/subset-tests-by-key.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: variant=?include=baseline +// META: variant=?include=from-local +// META: variant=?include=from-private +// META: variant=?include=from-public +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that secure contexts can fetch subresources from all +// address spaces, provided that the target server, if more private than the +// initiator, respond affirmatively to preflight requests. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: fetch.window.js + +setup(() => { + // Making sure we are in a secure context, as expected. + assert_true(window.isSecureContext); +}); + +// Source: secure local context. +// +// All fetches unaffected by Private Network Access. + +subsetTestByKey("from-local", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: FetchTestResult.SUCCESS, +}), "local to local: no preflight required."); + +subsetTestByKey("from-local", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "local to private: no preflight required."); + + +subsetTestByKey("from-local", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "local to public: no preflight required."); + +// Strictly speaking, the following two tests do not exercise PNA-specific +// logic, but they serve as a baseline for comparison, ensuring that non-PNA +// preflight requests are sent and handled as expected. + +subsetTestByKey("baseline", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { + preflight: PreflightBehavior.failure(), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { method: "PUT" }, + expected: FetchTestResult.FAILURE, +}), "local to public: PUT preflight failure."); + +subsetTestByKey("baseline", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + } + }, + fetchOptions: { method: "PUT" }, + expected: FetchTestResult.SUCCESS, +}), "local to public: PUT preflight success."); + +// Generates tests of preflight behavior for a single (source, target) pair. +// +// Scenarios: +// +// - cors mode: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - final response is missing CORS headers +// - success +// - success with PUT method (non-"simple" request) +// - no-cors mode: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - success +// +function makePreflightTests({ + subsetKey, + source, + sourceDescription, + targetServer, + targetDescription, +}) { + const prefix = + `${sourceDescription} to ${targetDescription}: `; + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.failure(), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, + }), prefix + "failed preflight."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.noCorsHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, + }), prefix + "missing CORS headers on preflight response."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, + }), prefix + "missing PNA header on preflight response."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + expected: FetchTestResult.FAILURE, + }), prefix + "missing CORS headers on final response."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }), prefix + "success."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { method: "PUT" }, + expected: FetchTestResult.SUCCESS, + }), prefix + "PUT success."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { server: targetServer }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, + }), prefix + "no-CORS mode failed preflight."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.noCorsHeader(token()) }, + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, + }), prefix + "no-CORS mode missing CORS headers on preflight response."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.noPnaHeader(token()) }, + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, + }), prefix + "no-CORS mode missing PNA header on preflight response."); + + subsetTestByKey(subsetKey, promise_test, t => fetchTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, + }), prefix + "no-CORS mode success."); +} + +// Source: private secure context. +// +// Fetches to the local address space require a successful preflight response +// carrying a PNA-specific header. + +makePreflightTests({ + subsetKey: "from-private", + source: { server: Server.HTTPS_PRIVATE }, + sourceDescription: "private", + targetServer: Server.HTTPS_LOCAL, + targetDescription: "local", +}); + +subsetTestByKey("from-private", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: FetchTestResult.SUCCESS, +}), "private to private: no preflight required."); + +subsetTestByKey("from-private", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "private to public: no preflight required."); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require a successful +// preflight response carrying a PNA-specific header. + +makePreflightTests({ + subsetKey: "from-public", + source: { server: Server.HTTPS_PUBLIC }, + sourceDescription: "public", + targetServer: Server.HTTPS_LOCAL, + targetDescription: "local", +}); + +makePreflightTests({ + subsetKey: "from-public", + source: { server: Server.HTTPS_PUBLIC }, + sourceDescription: "public", + targetServer: Server.HTTPS_PRIVATE, + targetDescription: "private", +}); + +subsetTestByKey("from-public", promise_test, t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: FetchTestResult.SUCCESS, +}), "public to public: no preflight required."); + diff --git a/test/wpt/tests/fetch/private-network-access/fetch.tentative.window.js b/test/wpt/tests/fetch/private-network-access/fetch.tentative.window.js new file mode 100644 index 0000000..8ee54c9 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/fetch.tentative.window.js @@ -0,0 +1,183 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that non-secure contexts cannot fetch subresources from +// less-public address spaces, and can fetch them otherwise. +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: fetch.https.window.js + +setup(() => { + // Making sure we are in a non secure context, as expected. + assert_false(window.isSecureContext); +}); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: FetchTestResult.SUCCESS, +}), "local to local: no preflight required."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "local to private: no preflight required."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "local to public: no preflight required."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: FetchTestResult.SUCCESS, +}), "private to private: no preflight required."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "private to public: no preflight required."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: FetchTestResult.SUCCESS, +}), "public to public: no preflight required."); + +// These tests verify that documents fetched from the `local` address space yet +// carrying the `treat-as-public-address` CSP directive are treated as if they +// had been fetched from the `public` address space. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public-address to local: failure."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public-address to private: failure."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public-address to public: no preflight required."); + +// These tests verify that HTTPS iframes embedded in an HTTP top-level document +// cannot fetch subresources from less-public address spaces. Indeed, even +// though the iframes have HTTPS origins, they are non-secure contexts because +// their parent is a non-secure context. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "private https to local: failure."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "public https to local: failure."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.FAILURE, +}), "public https to private: failure."); diff --git a/test/wpt/tests/fetch/private-network-access/iframe.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/iframe.tentative.https.window.js new file mode 100644 index 0000000..0c12970 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/iframe.tentative.https.window.js @@ -0,0 +1,266 @@ +// META: script=/common/subset-tests-by-key.js +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: timeout=long +// META: variant=?include=from-local +// META: variant=?include=from-private +// META: variant=?include=from-public +// META: variant=?include=from-treat-as-public +// META: variant=?include=grandparent +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that contexts can navigate iframes to less-public address +// spaces iff the target server responds affirmatively to preflight requests. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: iframe.tentative.window.js + +setup(() => { + assert_true(window.isSecureContext); +}); + +// Source: secure local context. +// +// All fetches unaffected by Private Network Access. + +subsetTestByKey("from-local", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: FrameTestResult.SUCCESS, +}), "local to local: no preflight required."); + +subsetTestByKey("from-local", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_PRIVATE }, + expected: FrameTestResult.SUCCESS, +}), "local to private: no preflight required."); + +subsetTestByKey("from-local", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "local to public: no preflight required."); + +// Generates tests of preflight behavior for a single (source, target) pair. +// +// Scenarios: +// +// - parent navigates child: +// - preflight response has non-2xx HTTP code +// - preflight response is missing CORS headers +// - preflight response is missing the PNA-specific `Access-Control` header +// - success +// +function makePreflightTests({ + key, + sourceName, + sourceServer, + sourceTreatAsPublic, + targetName, + targetServer, +}) { + const prefix = + `${sourceName} to ${targetName}: `; + + const source = { + server: sourceServer, + treatAsPublic: sourceTreatAsPublic, + }; + + promise_test_parallel(t => iframeTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.failure() }, + }, + expected: FrameTestResult.FAILURE, + }), prefix + "failed preflight."); + + promise_test_parallel(t => iframeTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.noCorsHeader(token()) }, + }, + expected: FrameTestResult.FAILURE, + }), prefix + "missing CORS headers."); + + promise_test_parallel(t => iframeTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.noPnaHeader(token()) }, + }, + expected: FrameTestResult.FAILURE, + }), prefix + "missing PNA header."); + + promise_test_parallel(t => iframeTest(t, { + source, + target: { + server: targetServer, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + expected: FrameTestResult.SUCCESS, + }), prefix + "success."); +} + +// Source: private secure context. +// +// Fetches to the local address space require a successful preflight response +// carrying a PNA-specific header. + +subsetTestByKey('from-private', makePreflightTests, { + sourceServer: Server.HTTPS_PRIVATE, + sourceName: 'private', + targetServer: Server.HTTPS_LOCAL, + targetName: 'local', +}); + +subsetTestByKey("from-private", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: FrameTestResult.SUCCESS, +}), "private to private: no preflight required."); + +subsetTestByKey("from-private", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "private to public: no preflight required."); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require a successful +// preflight response carrying a PNA-specific header. + +subsetTestByKey('from-public', makePreflightTests, { + sourceServer: Server.HTTPS_PUBLIC, + sourceName: "public", + targetServer: Server.HTTPS_LOCAL, + targetName: "local", +}); + +subsetTestByKey('from-public', makePreflightTests, { + sourceServer: Server.HTTPS_PUBLIC, + sourceName: "public", + targetServer: Server.HTTPS_PRIVATE, + targetName: "private", +}); + +subsetTestByKey("from-public", promise_test_parallel, t => iframeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "public to public: no preflight required."); + +// The following tests verify that `CSP: treat-as-public-address` makes +// documents behave as if they had been served from a public IP address. + +subsetTestByKey('from-treat-as-public', makePreflightTests, { + sourceServer: Server.HTTPS_LOCAL, + sourceTreatAsPublic: true, + sourceName: "treat-as-public-address", + targetServer: Server.OTHER_HTTPS_LOCAL, + targetName: "local", +}); + +subsetTestByKey( + 'from-treat-as-public', promise_test_parallel, + t => iframeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: {server: Server.HTTPS_LOCAL}, + expected: FrameTestResult.SUCCESS, + }), + 'treat-as-public-address to local (same-origin): no preflight required.' +); + +subsetTestByKey('from-treat-as-public', makePreflightTests, { + sourceServer: Server.HTTPS_LOCAL, + sourceTreatAsPublic: true, + sourceName: "treat-as-public-address", + targetServer: Server.HTTPS_PRIVATE, + targetName: "private", +}); + +subsetTestByKey( + 'from-treat-as-public', promise_test_parallel, + t => iframeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: {server: Server.HTTPS_PUBLIC}, + expected: FrameTestResult.SUCCESS, + }), + 'treat-as-public-address to public: no preflight required.' +); + +subsetTestByKey( + 'from-treat-as-public', promise_test_parallel, + t => iframeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: {preflight: PreflightBehavior.optionalSuccess(token())} + }, + expected: FrameTestResult.SUCCESS, + }), + 'treat-as-public-address to local: optional preflight' +); + +// The following tests verify that when a grandparent frame navigates its +// grandchild, the IP address space of the grandparent is compared against the +// IP address space of the response. Indeed, the navigation initiator in this +// case is the grandparent, not the parent. + +subsetTestByKey('grandparent', iframeGrandparentTest, { + name: 'local to local, grandparent navigates: no preflight required.', + grandparentServer: Server.HTTPS_LOCAL, + child: {server: Server.HTTPS_PUBLIC}, + grandchild: {server: Server.OTHER_HTTPS_LOCAL}, + expected: FrameTestResult.SUCCESS, +}); + +subsetTestByKey('grandparent', iframeGrandparentTest, { + name: "local to local (same-origin), grandparent navigates: no preflight required.", + grandparentServer: Server.HTTPS_LOCAL, + child: { server: Server.HTTPS_PUBLIC }, + grandchild: { server: Server.HTTPS_LOCAL }, + expected: FrameTestResult.SUCCESS, +}); + +subsetTestByKey('grandparent', iframeGrandparentTest, { + name: "public to local, grandparent navigates: failure.", + grandparentServer: Server.HTTPS_PUBLIC, + child: { + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + grandchild: { + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.failure() }, + }, + expected: FrameTestResult.FAILURE, +}); + +subsetTestByKey('grandparent', iframeGrandparentTest, { + name: "public to local, grandparent navigates: success.", + grandparentServer: Server.HTTPS_PUBLIC, + child: { + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + grandchild: { + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }, + expected: FrameTestResult.SUCCESS, +}); diff --git a/test/wpt/tests/fetch/private-network-access/iframe.tentative.window.js b/test/wpt/tests/fetch/private-network-access/iframe.tentative.window.js new file mode 100644 index 0000000..c0770df --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/iframe.tentative.window.js @@ -0,0 +1,110 @@ +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that non-secure contexts cannot navigate iframes to +// less-public address spaces, and can navigate them otherwise. +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: iframe.https.window.js + +setup(() => { + // Making sure we are in a non secure context, as expected. + assert_false(window.isSecureContext); +}); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: FrameTestResult.SUCCESS, +}), "local to local: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_PRIVATE }, + expected: FrameTestResult.SUCCESS, +}), "local to private: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "local to public: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_LOCAL }, + expected: FrameTestResult.FAILURE, +}), "private to local: failure."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: FrameTestResult.SUCCESS, +}), "private to private: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "private to public: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_LOCAL }, + expected: FrameTestResult.FAILURE, +}), "public to local: failure."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PRIVATE }, + expected: FrameTestResult.FAILURE, +}), "public to private: failure."); + +promise_test_parallel(t => iframeTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "public to public: no preflight required."); + +promise_test_parallel(t => iframeTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_LOCAL }, + expected: FrameTestResult.FAILURE, +}), "treat-as-public-address to local: failure."); + +promise_test_parallel(t => iframeTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_PRIVATE }, + expected: FrameTestResult.FAILURE, +}), "treat-as-public-address to private: failure."); + +promise_test_parallel(t => iframeTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_PUBLIC }, + expected: FrameTestResult.SUCCESS, +}), "treat-as-public-address to public: no preflight required."); + +// The following test verifies that when a grandparent frame navigates its +// grandchild, the IP address space of the grandparent is compared against the +// IP address space of the response. Indeed, the navigation initiator in this +// case is the grandparent, not the parent. + +iframeGrandparentTest({ + name: "local to local, grandparent navigates: success.", + grandparentServer: Server.HTTP_LOCAL, + child: { server: Server.HTTP_PUBLIC }, + grandchild: { server: Server.HTTP_LOCAL }, + expected: FrameTestResult.SUCCESS, +}); diff --git a/test/wpt/tests/fetch/private-network-access/mixed-content-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/mixed-content-fetch.tentative.https.window.js new file mode 100644 index 0000000..54485dc --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/mixed-content-fetch.tentative.https.window.js @@ -0,0 +1,277 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access +// +// These tests verify that secure contexts can fetch non-secure subresources +// from more private address spaces, avoiding mixed context checks, as long as +// they specify a valid `targetAddressSpace` fetch option that matches the +// target server's address space. + +setup(() => { + // Making sure we are in a secure context, as expected. + assert_true(window.isSecureContext); +}); + +// Given `addressSpace`, returns the other three possible IP address spaces. +function otherAddressSpaces(addressSpace) { + switch (addressSpace) { + case "local": return ["unknown", "private", "public"]; + case "private": return ["unknown", "local", "public"]; + case "public": return ["unknown", "local", "private"]; + } +} + +// Generates tests of `targetAddressSpace` for the given (source, target) +// address space pair, expecting fetches to succeed iff `targetAddressSpace` is +// correct. +// +// Scenarios exercised: +// +// - cors mode: +// - missing targetAddressSpace option +// - incorrect targetAddressSpace option (x3, see `otherAddressSpaces()`) +// - failed preflight +// - success +// - success with PUT method (non-"simple" request) +// - no-cors mode: +// - success +// +function makeTests({ source, target }) { + const sourceServer = Server.get("https", source); + const targetServer = Server.get("http", target); + + const makeTest = ({ + fetchOptions, + targetBehavior, + name, + expected + }) => { + promise_test_parallel(t => fetchTest(t, { + source: { server: sourceServer }, + target: { + server: targetServer, + behavior: targetBehavior, + }, + fetchOptions, + expected, + }), `${sourceServer.name} to ${targetServer.name}: ${name}.`); + }; + + makeTest({ + name: "missing targetAddressSpace", + targetBehavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + expected: FetchTestResult.FAILURE, + }); + + const correctAddressSpace = targetServer.addressSpace; + + for (const targetAddressSpace of otherAddressSpaces(correctAddressSpace)) { + makeTest({ + name: `wrong targetAddressSpace "${targetAddressSpace}"`, + targetBehavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + fetchOptions: { targetAddressSpace }, + expected: FetchTestResult.FAILURE, + }); + } + + makeTest({ + name: "failed preflight", + targetBehavior: { + preflight: PreflightBehavior.failure(), + response: ResponseBehavior.allowCrossOrigin(), + }, + fetchOptions: { targetAddressSpace: correctAddressSpace }, + expected: FetchTestResult.FAILURE, + }); + + makeTest({ + name: "success", + targetBehavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + fetchOptions: { targetAddressSpace: correctAddressSpace }, + expected: FetchTestResult.SUCCESS, + }); + + makeTest({ + name: "PUT success", + targetBehavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + fetchOptions: { + targetAddressSpace: correctAddressSpace, + method: "PUT", + }, + expected: FetchTestResult.SUCCESS, + }); + + makeTest({ + name: "no-cors success", + targetBehavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + fetchOptions: { + targetAddressSpace: correctAddressSpace, + mode: "no-cors", + }, + expected: FetchTestResult.OPAQUE, + }); +} + +// Generates tests for the given (source, target) address space pair expecting +// that `targetAddressSpace` cannot be used to bypass mixed content. +// +// Scenarios exercised: +// +// - wrong `targetAddressSpace` (x3, see `otherAddressSpaces()`) +// - correct `targetAddressSpace` +// +function makeNoBypassTests({ source, target }) { + const sourceServer = Server.get("https", source); + const targetServer = Server.get("http", target); + + const prefix = `${sourceServer.name} to ${targetServer.name}: `; + + const correctAddressSpace = targetServer.addressSpace; + for (const targetAddressSpace of otherAddressSpaces(correctAddressSpace)) { + promise_test_parallel(t => fetchTest(t, { + source: { server: sourceServer }, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace }, + expected: FetchTestResult.FAILURE, + }), prefix + `wrong targetAddressSpace "${targetAddressSpace}".`); + } + + promise_test_parallel(t => fetchTest(t, { + source: { server: sourceServer }, + target: { + server: targetServer, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace: correctAddressSpace }, + expected: FetchTestResult.FAILURE, + }), prefix + 'not a private network request.'); +} + +// Source: local secure context. +// +// Fetches to the local and private address spaces cannot use +// `targetAddressSpace` to bypass mixed content, as they are not otherwise +// blocked by Private Network Access. + +makeNoBypassTests({ source: "local", target: "local" }); +makeNoBypassTests({ source: "local", target: "private" }); +makeNoBypassTests({ source: "local", target: "public" }); + +// Source: private secure context. +// +// Fetches to the local address space requires the right `targetAddressSpace` +// option, as well as a successful preflight response carrying a PNA-specific +// header. +// +// Fetches to the private address space cannot use `targetAddressSpace` to +// bypass mixed content, as they are not otherwise blocked by Private Network +// Access. + +makeTests({ source: "private", target: "local" }); + +makeNoBypassTests({ source: "private", target: "private" }); +makeNoBypassTests({ source: "private", target: "public" }); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require the right +// `targetAddressSpace` option, as well as a successful preflight response +// carrying a PNA-specific header. + +makeTests({ source: "public", target: "local" }); +makeTests({ source: "public", target: "private" }); + +makeNoBypassTests({ source: "public", target: "public" }); + +// These tests verify that documents fetched from the `local` address space yet +// carrying the `treat-as-public-address` CSP directive are treated as if they +// had been fetched from the `public` address space. + +promise_test_parallel(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace: "private" }, + expected: FetchTestResult.FAILURE, +}), 'https-treat-as-public to http-local: wrong targetAddressSpace "private".'); + +promise_test_parallel(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace: "local" }, + expected: FetchTestResult.SUCCESS, +}), "https-treat-as-public to http-local: success."); + +promise_test_parallel(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace: "local" }, + expected: FetchTestResult.FAILURE, +}), 'https-treat-as-public to http-private: wrong targetAddressSpace "local".'); + +promise_test_parallel(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + fetchOptions: { targetAddressSpace: "private" }, + expected: FetchTestResult.SUCCESS, +}), "https-treat-as-public to http-private: success."); diff --git a/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.https.window.js new file mode 100644 index 0000000..3eeb435 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.https.window.js @@ -0,0 +1,36 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that initial `Worker` script fetches from within worker +// scopes are subject to Private Network Access checks, just like a worker +// script fetches from within document scopes (for non-nested workers). The +// latter are tested in: worker.https.window.js +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: nested-worker.window.js + +promise_test(t => nestedWorkerScriptTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => nestedWorkerScriptTest(t, { + source: { + server: Server.HTTPS_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => nestedWorkerScriptTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.window.js b/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.window.js new file mode 100644 index 0000000..6d246e1 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/nested-worker.tentative.window.js @@ -0,0 +1,36 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that initial `Worker` script fetches from within worker +// scopes are subject to Private Network Access checks, just like a worker +// script fetches from within document scopes (for non-nested workers). The +// latter are tested in: worker.window.js +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: nested-worker.https.window.js + +promise_test(t => nestedWorkerScriptTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => nestedWorkerScriptTest(t, { + source: { + server: Server.HTTP_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => nestedWorkerScriptTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/preflight-cache.https.tentative.window.js b/test/wpt/tests/fetch/private-network-access/preflight-cache.https.tentative.window.js new file mode 100644 index 0000000..87dbf50 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/preflight-cache.https.tentative.window.js @@ -0,0 +1,88 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#cors-preflight +// +// These tests verify that PNA preflight responses are cached. +// +// TODO(https://crbug.com/1268312): We cannot currently test that cache +// entries are keyed by target IP address space because that requires +// loading the same URL from different IP address spaces, and the WPT +// framework does not allow that. +promise_test(async t => { + let uuid = token(); + await fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); + await fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); +}, "private to local: success."); + +promise_test(async t => { + let uuid = token(); + await fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); + await fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); +}, "public to local: success."); + +promise_test(async t => { + let uuid = token(); + await fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); + await fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.singlePreflight(uuid), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: FetchTestResult.SUCCESS, + }); +}, "public to private: success."); \ No newline at end of file diff --git a/test/wpt/tests/fetch/private-network-access/redirect.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/redirect.tentative.https.window.js new file mode 100644 index 0000000..efbd8f3 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/redirect.tentative.https.window.js @@ -0,0 +1,640 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// This test verifies that Private Network Access checks are applied to all +// the endpoints in a redirect chain, relative to the same client context. + +// local -> private -> public +// +// Request 1 (local -> private): no preflight. +// Request 2 (local -> public): no preflight. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "local to private to public: success."); + +// local -> private -> local +// +// Request 1 (local -> private): no preflight. +// Request 2 (local -> local): no preflight. +// +// This checks that the client for the second request is still the initial +// context, not the redirector. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "local to private to local: success."); + +// private -> private -> local +// +// Request 1 (private -> private): no preflight. +// Request 2 (private -> local): preflight required. +// +// This verifies that PNA checks are applied after redirects. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "private to private to local: failed preflight."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "private to private to local: success."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "private to private to local: no-cors success."); + +// private -> local -> private +// +// Request 1 (private -> local): preflight required. +// Request 2 (private -> private): no preflight. +// +// This verifies that PNA checks are applied independently to every step in a +// redirect chain. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "private to local to private: failed preflight."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "private to local to private: success."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ server: Server.HTTPS_PRIVATE }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "private to local to private: no-cors success."); + +// public -> private -> local +// +// Request 1 (public -> private): preflight required. +// Request 2 (public -> local): preflight required. +// +// This verifies that PNA checks are applied to every step in a redirect chain. + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "public to private to local: failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "public to private to local: failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "public to private to local: success."); + +promise_test(t => fetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "public to private to local: no-cors success."); + +// treat-as-public -> local -> private + +// Request 1 (treat-as-public -> local): preflight required. +// Request 2 (treat-as-public -> private): preflight required. + +// This verifies that PNA checks are applied to every step in a redirect chain. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + response: ResponseBehavior.allowCrossOrigin(), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local to private: failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + response: ResponseBehavior.allowCrossOrigin(), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local to private: failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + response: ResponseBehavior.allowCrossOrigin(), + } + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public to local to private: success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local to private: no-cors failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ server: Server.HTTPS_PRIVATE }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local to private: no-cors failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "treat-as-public to local to private: no-cors success."); + +// treat-as-public -> local (same-origin) -> private + +// Request 1 (treat-as-public -> local (same-origin)): no preflight required. +// Request 2 (treat-as-public -> private): preflight required. + +// This verifies that PNA checks are applied only to the second step in a +// redirect chain if the first step is same-origin and the origin is potentially +// trustworthy. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local (same-origin) to private: failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public to local (same-origin) to private: success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ server: Server.HTTPS_PRIVATE }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to local (same-origin) to private: no-cors failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + redirect: preflightUrl({ + server: Server.HTTPS_PRIVATE, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "treat-as-public to local (same-origin) to private: no-cors success."); + +// treat-as-public -> private -> local + +// Request 1 (treat-as-public -> private): preflight required. +// Request 2 (treat-as-public -> local): preflight required. + +// This verifies that PNA checks are applied to every step in a redirect chain. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local: failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.OTHER_HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local: failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public to private to local: success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + redirect: preflightUrl({ + server: Server.OTHER_HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local: no-cors failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ server: Server.OTHER_HTTPS_LOCAL }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local: no-cors failed second preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ + server: Server.OTHER_HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.success(token()) }, + }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "treat-as-public to private to local: no-cors success."); + +// treat-as-public -> private -> local (same-origin) + +// Request 1 (treat-as-public -> private): preflight required. +// Request 2 (treat-as-public -> local (same-origin)): no preflight required. + +// This verifies that PNA checks are only applied to the first step in a +// redirect chain if the second step is same-origin and the origin is +// potentially trustworthy. + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.noPnaHeader(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local (same-origin): failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + redirect: preflightUrl({ + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }), + } + }, + expected: FetchTestResult.SUCCESS, +}), "treat-as-public to private to local (same-origin): success."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + redirect: preflightUrl({ server: Server.HTTPS_LOCAL }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.FAILURE, +}), "treat-as-public to private to local (same-origin): no-cors failed first preflight."); + +promise_test(t => fetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + redirect: preflightUrl({ server: Server.HTTPS_LOCAL }), + } + }, + fetchOptions: { mode: "no-cors" }, + expected: FetchTestResult.OPAQUE, +}), "treat-as-public to private to local (same-origin): no-cors success."); diff --git a/test/wpt/tests/fetch/private-network-access/resources/executor.html b/test/wpt/tests/fetch/private-network-access/resources/executor.html new file mode 100644 index 0000000..d712129 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/executor.html @@ -0,0 +1,9 @@ + + +Executor + + + diff --git a/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html new file mode 100644 index 0000000..b14601d --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html @@ -0,0 +1,25 @@ + + + +Fetcher + \ No newline at end of file diff --git a/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html.headers b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html.headers new file mode 100644 index 0000000..6247f6d --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-fetcher.https.html.headers @@ -0,0 +1 @@ +Supports-Loading-Mode: fenced-frame \ No newline at end of file diff --git a/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access-target.https.html b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access-target.https.html new file mode 100644 index 0000000..2b55e05 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access-target.https.html @@ -0,0 +1,8 @@ + + + +Fenced frame target + diff --git a/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html new file mode 100644 index 0000000..98f1184 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html @@ -0,0 +1,14 @@ + + + + + +Fenced frame + + diff --git a/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html.headers b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html.headers new file mode 100644 index 0000000..6247f6d --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fenced-frame-local-network-access.https.html.headers @@ -0,0 +1 @@ +Supports-Loading-Mode: fenced-frame \ No newline at end of file diff --git a/test/wpt/tests/fetch/private-network-access/resources/fetcher.html b/test/wpt/tests/fetch/private-network-access/resources/fetcher.html new file mode 100644 index 0000000..000a5cc --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fetcher.html @@ -0,0 +1,21 @@ + + +Fetcher + diff --git a/test/wpt/tests/fetch/private-network-access/resources/fetcher.js b/test/wpt/tests/fetch/private-network-access/resources/fetcher.js new file mode 100644 index 0000000..3a18598 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/fetcher.js @@ -0,0 +1,20 @@ +async function doFetch(url) { + const response = await fetch(url); + const body = await response.text(); + return { + status: response.status, + body, + }; +} + +async function fetchAndPost(url) { + try { + const message = await doFetch(url); + self.postMessage(message); + } catch(e) { + self.postMessage({ error: e.name }); + } +} + +const url = new URL(self.location.href).searchParams.get("url"); +fetchAndPost(url); diff --git a/test/wpt/tests/fetch/private-network-access/resources/iframed.html b/test/wpt/tests/fetch/private-network-access/resources/iframed.html new file mode 100644 index 0000000..c889c28 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/iframed.html @@ -0,0 +1,7 @@ + + +Iframed + diff --git a/test/wpt/tests/fetch/private-network-access/resources/iframer.html b/test/wpt/tests/fetch/private-network-access/resources/iframer.html new file mode 100644 index 0000000..304cc54 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/iframer.html @@ -0,0 +1,9 @@ + + +Iframer + + diff --git a/test/wpt/tests/fetch/private-network-access/resources/preflight.py b/test/wpt/tests/fetch/private-network-access/resources/preflight.py new file mode 100644 index 0000000..be3abdb --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/preflight.py @@ -0,0 +1,175 @@ +# This endpoint responds to both preflight requests and the subsequent requests. +# +# Its behavior can be configured with various search/GET parameters, all of +# which are optional: +# +# - treat-as-public-once: Must be a valid UUID if set. +# If set, then this endpoint expects to receive a non-preflight request first, +# for which it sets the `Content-Security-Policy: treat-as-public-address` +# response header. This allows testing "DNS rebinding", where a URL first +# resolves to the public IP address space, then a non-public IP address space. +# - preflight-uuid: Must be a valid UUID if set, distinct from the value of the +# `treat-as-public-once` parameter if both are set. +# If set, then this endpoint expects to receive a preflight request first +# followed by a regular request, as in the regular CORS protocol. If the +# `treat-as-public-once` header is also set, it takes precedence: this +# endpoint expects to receive a non-preflight request first, then a preflight +# request, then finally a regular request. +# If unset, then this endpoint expects to receive no preflight request, only +# a regular (non-OPTIONS) request. +# - preflight-headers: Valid values are: +# - cors: this endpoint responds with valid CORS headers to preflights. These +# should be sufficient for non-PNA preflight requests to succeed, but not +# for PNA-specific preflight requests. +# - cors+pna: this endpoint responds with valid CORS and PNA headers to +# preflights. These should be sufficient for both non-PNA preflight +# requests and PNA-specific preflight requests to succeed. +# - cors+pna+sw: this endpoint responds with valid CORS and PNA headers and +# "Access-Control-Allow-Headers: Service-Worker" to preflights. These should +# be sufficient for both non-PNA preflight requests and PNA-specific +# preflight requests to succeed. This allows the main request to fetch a +# service worker script. +# - unspecified, or any other value: this endpoint responds with no CORS or +# PNA headers. Preflight requests should fail. +# - final-headers: Valid values are: +# - cors: this endpoint responds with valid CORS headers to CORS-enabled +# non-preflight requests. These should be sufficient for non-preflighted +# CORS-enabled requests to succeed. +# - unspecified: this endpoint responds with no CORS headers to non-preflight +# requests. This should fail CORS-enabled requests, but be sufficient for +# no-CORS requests. +# +# The following parameters only affect non-preflight responses: +# +# - redirect: If set, the response code is set to 301 and the `Location` +# response header is set to this value. +# - mime-type: If set, the `Content-Type` response header is set to this value. +# - file: Specifies a path (relative to this file's directory) to a file. If +# set, the response body is copied from this file. +# - random-js-prefix: If set to any value, the response body is prefixed with +# a Javascript comment line containing a random value. This is useful in +# service worker tests, since service workers are only updated if the new +# script is not byte-for-byte identical with the old script. +# - body: If set and `file` is not, the response body is set to this value. +# + +import os +import random + +from wptserve.utils import isomorphic_encode + +_ACAO = ("Access-Control-Allow-Origin", "*") +_ACAPN = ("Access-Control-Allow-Private-Network", "true") +_ACAH = ("Access-Control-Allow-Headers", "Service-Worker") + +def _get_response_headers(method, mode): + acam = ("Access-Control-Allow-Methods", method) + + if mode == b"cors": + return [acam, _ACAO] + + if mode == b"cors+pna": + return [acam, _ACAO, _ACAPN] + + if mode == b"cors+pna+sw": + return [acam, _ACAO, _ACAPN, _ACAH] + + return [] + +def _get_expect_single_preflight(request): + return request.GET.get(b"expect-single-preflight") + +def _is_preflight_optional(request): + return request.GET.get(b"is-preflight-optional") + +def _get_preflight_uuid(request): + return request.GET.get(b"preflight-uuid") + +def _is_loaded_in_fenced_frame(request): + return request.GET.get(b"is-loaded-in-fenced-frame") + +def _should_treat_as_public_once(request): + uuid = request.GET.get(b"treat-as-public-once") + if uuid is None: + # If the search parameter is not given, never treat as public. + return False + + # If the parameter is given, we treat the request as public only if the UUID + # has never been seen and stashed. + result = request.server.stash.take(uuid) is None + request.server.stash.put(uuid, "") + return result + +def _handle_preflight_request(request, response): + if _should_treat_as_public_once(request): + return (400, [], "received preflight for first treat-as-public request") + + uuid = _get_preflight_uuid(request) + if uuid is None: + return (400, [], "missing `preflight-uuid` param from preflight URL") + + value = request.server.stash.take(uuid) + request.server.stash.put(uuid, "preflight") + if _get_expect_single_preflight(request) and value is not None: + return (400, [], "received duplicated preflight") + + method = request.headers.get("Access-Control-Request-Method") + mode = request.GET.get(b"preflight-headers") + headers = _get_response_headers(method, mode) + + return (headers, "preflight") + +def _final_response_body(request): + file_name = request.GET.get(b"file") + if file_name is None: + return request.GET.get(b"body") or "success" + + prefix = b"" + if request.GET.get(b"random-js-prefix"): + value = random.randint(0, 1000000000) + prefix = isomorphic_encode("// Random value: {}\n\n".format(value)) + + path = os.path.join(os.path.dirname(isomorphic_encode(__file__)), file_name) + with open(path, 'rb') as f: + contents = f.read() + + return prefix + contents + +def _handle_final_request(request, response): + if _should_treat_as_public_once(request): + headers = [("Content-Security-Policy", "treat-as-public-address"),] + else: + uuid = _get_preflight_uuid(request) + if uuid is not None: + if (request.server.stash.take(uuid) is None and + not _is_preflight_optional(request)): + return (405, [], "no preflight received") + request.server.stash.put(uuid, "final") + + mode = request.GET.get(b"final-headers") + headers = _get_response_headers(request.method, mode) + + redirect = request.GET.get(b"redirect") + if redirect is not None: + headers.append(("Location", redirect)) + return (301, headers, b"") + + mime_type = request.GET.get(b"mime-type") + if mime_type is not None: + headers.append(("Content-Type", mime_type),) + + if _is_loaded_in_fenced_frame(request): + headers.append(("Supports-Loading-Mode", "fenced-frame")) + + body = _final_response_body(request) + return (headers, body) + +def main(request, response): + try: + if request.method == "OPTIONS": + return _handle_preflight_request(request, response) + else: + return _handle_final_request(request, response) + except BaseException as e: + # Surface exceptions to the client, where they show up as assertion errors. + return (500, [("X-exception", str(e))], "exception: {}".format(e)) diff --git a/test/wpt/tests/fetch/private-network-access/resources/service-worker-bridge.html b/test/wpt/tests/fetch/private-network-access/resources/service-worker-bridge.html new file mode 100644 index 0000000..816de53 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/service-worker-bridge.html @@ -0,0 +1,155 @@ + + +ServiceWorker Bridge + + + diff --git a/test/wpt/tests/fetch/private-network-access/resources/service-worker.js b/test/wpt/tests/fetch/private-network-access/resources/service-worker.js new file mode 100644 index 0000000..bca71ad --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/service-worker.js @@ -0,0 +1,18 @@ +self.addEventListener("install", () => { + // Skip waiting before replacing the previously-active service worker, if any. + // This allows the bridge script to notice the controller change and query + // the install time via fetch. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + // Claim all clients so that the bridge script notices the activation. + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url).searchParams.get("proxied-url"); + if (url) { + event.respondWith(fetch(url)); + } +}); diff --git a/test/wpt/tests/fetch/private-network-access/resources/shared-fetcher.js b/test/wpt/tests/fetch/private-network-access/resources/shared-fetcher.js new file mode 100644 index 0000000..30bde1e --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/shared-fetcher.js @@ -0,0 +1,23 @@ +async function doFetch(url) { + const response = await fetch(url); + const body = await response.text(); + return { + status: response.status, + body, + }; +} + +async function fetchAndPost(url, port) { + try { + const message = await doFetch(url); + port.postMessage(message); + } catch(e) { + port.postMessage({ error: e.name }); + } +} + +const url = new URL(self.location.href).searchParams.get("url"); + +self.addEventListener("connect", async (evt) => { + await fetchAndPost(url, evt.ports[0]); +}); diff --git a/test/wpt/tests/fetch/private-network-access/resources/shared-worker-blob-fetcher.html b/test/wpt/tests/fetch/private-network-access/resources/shared-worker-blob-fetcher.html new file mode 100644 index 0000000..a79869b --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/shared-worker-blob-fetcher.html @@ -0,0 +1,50 @@ + + +SharedWorker Blob Fetcher + diff --git a/test/wpt/tests/fetch/private-network-access/resources/shared-worker-fetcher.html b/test/wpt/tests/fetch/private-network-access/resources/shared-worker-fetcher.html new file mode 100644 index 0000000..4af4b1f --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/shared-worker-fetcher.html @@ -0,0 +1,19 @@ + + +SharedWorker Fetcher + diff --git a/test/wpt/tests/fetch/private-network-access/resources/socket-opener.html b/test/wpt/tests/fetch/private-network-access/resources/socket-opener.html new file mode 100644 index 0000000..48d2721 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/socket-opener.html @@ -0,0 +1,15 @@ + + +WebSocket Opener + diff --git a/test/wpt/tests/fetch/private-network-access/resources/support.sub.js b/test/wpt/tests/fetch/private-network-access/resources/support.sub.js new file mode 100644 index 0000000..27d733d --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/support.sub.js @@ -0,0 +1,759 @@ +// Creates a new iframe in `doc`, calls `func` on it and appends it as a child +// of `doc`. +// Returns a promise that resolves to the iframe once loaded (successfully or +// not). +// The iframe is removed from `doc` once test `t` is done running. +// +// NOTE: There exists no interoperable way to check whether an iframe failed to +// load, so this should only be used when the iframe is expected to load. It +// also means we cannot wire the iframe's `error` event to a promise +// rejection. See: https://github.com/whatwg/html/issues/125 +function appendIframeWith(t, doc, func) { + return new Promise(resolve => { + const child = doc.createElement("iframe"); + t.add_cleanup(() => child.remove()); + + child.addEventListener("load", () => resolve(child), { once: true }); + func(child); + doc.body.appendChild(child); + }); +} + +// Appends a child iframe to `doc` sourced from `src`. +// +// See `appendIframeWith()` for more details. +function appendIframe(t, doc, src) { + return appendIframeWith(t, doc, child => { child.src = src; }); +} + +// Registers an event listener that will resolve this promise when this +// window receives a message posted to it. +// +// `options` has the following shape: +// +// { +// source: If specified, this function waits for the first message from the +// given source only, ignoring other messages. +// +// filter: If specified, this function calls `filter` on each incoming +// message, and resolves iff it returns true. +// } +// +function futureMessage(options) { + return new Promise(resolve => { + window.addEventListener("message", (e) => { + if (options?.source && options.source !== e.source) { + return; + } + + if (options?.filter && !options.filter(e.data)) { + return; + } + + resolve(e.data); + }); + }); +}; + +// Like `promise_test()`, but executes tests in parallel like `async_test()`. +// +// Cribbed from COEP tests. +function promise_test_parallel(promise, description) { + async_test(test => { + promise(test) + .then(() => test.done()) + .catch(test.step_func(error => { throw error; })); + }, description); +}; + +async function postMessageAndAwaitReply(target, message) { + const reply = futureMessage({ source: target }); + target.postMessage(message, "*"); + return await reply; +} + +// Maps protocol (without the trailing colon) and address space to port. +const SERVER_PORTS = { + "http": { + "local": {{ports[http][0]}}, + "private": {{ports[http-private][0]}}, + "public": {{ports[http-public][0]}}, + }, + "https": { + "local": {{ports[https][0]}}, + "other-local": {{ports[https][1]}}, + "private": {{ports[https-private][0]}}, + "public": {{ports[https-public][0]}}, + }, + "ws": { + "local": {{ports[ws][0]}}, + }, + "wss": { + "local": {{ports[wss][0]}}, + }, +}; + +// A `Server` is a web server accessible by tests. It has the following shape: +// +// { +// addressSpace: the IP address space of the server ("local", "private" or +// "public"), +// name: a human-readable name for the server, +// port: the port on which the server listens for connections, +// protocol: the protocol (including trailing colon) spoken by the server, +// } +// +// Constants below define the available servers, which can also be accessed +// programmatically with `get()`. +class Server { + // Maps the given `protocol` (without a trailing colon) and `addressSpace` to + // a server. Returns null if no such server exists. + static get(protocol, addressSpace) { + const ports = SERVER_PORTS[protocol]; + if (ports === undefined) { + return null; + } + + const port = ports[addressSpace]; + if (port === undefined) { + return null; + } + + return { + addressSpace, + name: `${protocol}-${addressSpace}`, + port, + protocol: protocol + ':', + }; + } + + static HTTP_LOCAL = Server.get("http", "local"); + static HTTP_PRIVATE = Server.get("http", "private"); + static HTTP_PUBLIC = Server.get("http", "public"); + static HTTPS_LOCAL = Server.get("https", "local"); + static OTHER_HTTPS_LOCAL = Server.get("https", "other-local"); + static HTTPS_PRIVATE = Server.get("https", "private"); + static HTTPS_PUBLIC = Server.get("https", "public"); + static WS_LOCAL = Server.get("ws", "local"); + static WSS_LOCAL = Server.get("wss", "local"); +}; + +// Resolves a URL relative to the current location, returning an absolute URL. +// +// `url` specifies the relative URL, e.g. "foo.html" or "http://foo.example". +// `options`, if defined, should have the following shape: +// +// { +// // Optional. Overrides the protocol of the returned URL. +// protocol, +// +// // Optional. Overrides the port of the returned URL. +// port, +// +// // Extra headers. +// headers, +// +// // Extra search params. +// searchParams, +// } +// +function resolveUrl(url, options) { + const result = new URL(url, window.location); + if (options === undefined) { + return result; + } + + const { port, protocol, headers, searchParams } = options; + if (port !== undefined) { + result.port = port; + } + if (protocol !== undefined) { + result.protocol = protocol; + } + if (headers !== undefined) { + const pipes = []; + for (key in headers) { + pipes.push(`header(${key},${headers[key]})`); + } + result.searchParams.append("pipe", pipes.join("|")); + } + if (searchParams !== undefined) { + for (key in searchParams) { + result.searchParams.append(key, searchParams[key]); + } + } + + return result; +} + +// Computes options to pass to `resolveUrl()` for a source document's URL. +// +// `server` identifies the server from which to load the document. +// `treatAsPublic`, if set to true, specifies that the source document should +// be artificially placed in the `public` address space using CSP. +function sourceResolveOptions({ server, treatAsPublic }) { + const options = {...server}; + if (treatAsPublic) { + options.headers = { "Content-Security-Policy": "treat-as-public-address" }; + } + return options; +} + +// Computes the URL of a preflight handler configured with the given options. +// +// `server` identifies the server from which to load the resource. +// `behavior` specifies the behavior of the target server. It may contain: +// - `preflight`: The result of calling one of `PreflightBehavior`'s methods. +// - `response`: The result of calling one of `ResponseBehavior`'s methods. +// - `redirect`: A URL to which the target should redirect GET requests. +function preflightUrl({ server, behavior }) { + assert_not_equals(server, undefined, 'server'); + const options = {...server}; + if (behavior) { + const { preflight, response, redirect } = behavior; + options.searchParams = { + ...preflight, + ...response, + }; + if (redirect !== undefined) { + options.searchParams.redirect = redirect; + } + } + + return resolveUrl("resources/preflight.py", options); +} + +// Methods generate behavior specifications for how `resources/preflight.py` +// should behave upon receiving a preflight request. +const PreflightBehavior = { + // The preflight response should fail with a non-2xx code. + failure: () => ({}), + + // The preflight response should be missing CORS headers. + // `uuid` should be a UUID that uniquely identifies the preflight request. + noCorsHeader: (uuid) => ({ + "preflight-uuid": uuid, + }), + + // The preflight response should be missing PNA headers. + // `uuid` should be a UUID that uniquely identifies the preflight request. + noPnaHeader: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors", + }), + + // The preflight response should succeed. + // `uuid` should be a UUID that uniquely identifies the preflight request. + success: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna", + }), + + optionalSuccess: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna", + "is-preflight-optional": true, + }), + + // The preflight response should succeed and allow service-worker header. + // `uuid` should be a UUID that uniquely identifies the preflight request. + serviceWorkerSuccess: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna+sw", + }), + + // The preflight response should succeed only if it is the first preflight. + // `uuid` should be a UUID that uniquely identifies the preflight request. + singlePreflight: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna", + "expect-single-preflight": true, + }), +}; + +// Methods generate behavior specifications for how `resources/preflight.py` +// should behave upon receiving a regular (non-preflight) request. +const ResponseBehavior = { + // The response should succeed without CORS headers. + default: () => ({}), + + // The response should succeed with CORS headers. + allowCrossOrigin: () => ({ "final-headers": "cors" }), +}; + +const FetchTestResult = { + SUCCESS: { + ok: true, + body: "success", + }, + OPAQUE: { + ok: false, + type: "opaque", + body: "", + }, + FAILURE: { + error: "TypeError: Failed to fetch", + }, +}; + +// Runs a fetch test. Tries to fetch a given subresource from a given document. +// +// Main argument shape: +// +// { +// // Optional. Passed to `sourceResolveOptions()`. +// source, +// +// // Optional. Passed to `preflightUrl()`. +// target, +// +// // Optional. Passed to `fetch()`. +// fetchOptions, +// +// // Required. One of the values in `FetchTestResult`. +// expected, +// } +// +async function fetchTest(t, { source, target, fetchOptions, expected }) { + const sourceUrl = + resolveUrl("resources/fetcher.html", sourceResolveOptions(source)); + + const targetUrl = preflightUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage({ source: iframe.contentWindow }); + + const message = { + url: targetUrl.href, + options: fetchOptions, + }; + iframe.contentWindow.postMessage(message, "*"); + + const { error, ok, type, body } = await reply; + + assert_equals(error, expected.error, "error"); + + assert_equals(ok, expected.ok, "response ok"); + assert_equals(body, expected.body, "response body"); + + if (expected.type !== undefined) { + assert_equals(type, expected.type, "response type"); + } +} + +// Similar to `fetchTest`, but replaced iframes with fenced frames. +async function fencedFrameFetchTest(t, { source, target, fetchOptions, expected }) { + const fetcher_url = + resolveUrl("resources/fenced-frame-fetcher.https.html", sourceResolveOptions(source)); + + const target_url = preflightUrl(target); + target_url.searchParams.set("is-loaded-in-fenced-frame", true); + + fetcher_url.searchParams.set("mode", fetchOptions.mode); + fetcher_url.searchParams.set("method", fetchOptions.method); + fetcher_url.searchParams.set("url", target_url); + + const error_token = token(); + const ok_token = token(); + const body_token = token(); + const type_token = token(); + const source_url = generateURL(fetcher_url, [error_token, ok_token, body_token, type_token]); + + const urn = await generateURNFromFledge(source_url, []); + attachFencedFrame(urn); + + const error = await nextValueFromServer(error_token); + const ok = await nextValueFromServer(ok_token); + const body = await nextValueFromServer(body_token); + const type = await nextValueFromServer(type_token); + + assert_equals(error, expected.error || "" , "error"); + assert_equals(body, expected.body || "", "response body"); + assert_equals(ok, expected.ok !== undefined ? expected.ok.toString() : "", "response ok"); + if (expected.type !== undefined) { + assert_equals(type, expected.type, "response type"); + } +} + +const XhrTestResult = { + SUCCESS: { + loaded: true, + status: 200, + body: "success", + }, + FAILURE: { + loaded: false, + status: 0, + }, +}; + +// Runs an XHR test. Tries to fetch a given subresource from a given document. +// +// Main argument shape: +// +// { +// // Optional. Passed to `sourceResolveOptions()`. +// source, +// +// // Optional. Passed to `preflightUrl()`. +// target, +// +// // Optional. Method to use when sending the request. Defaults to "GET". +// method, +// +// // Required. One of the values in `XhrTestResult`. +// expected, +// } +// +async function xhrTest(t, { source, target, method, expected }) { + const sourceUrl = + resolveUrl("resources/xhr-sender.html", sourceResolveOptions(source)); + + const targetUrl = preflightUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + const message = { + url: targetUrl.href, + method: method, + }; + iframe.contentWindow.postMessage(message, "*"); + + const { loaded, status, body } = await reply; + + assert_equals(loaded, expected.loaded, "response loaded"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} + +const FrameTestResult = { + SUCCESS: "loaded", + FAILURE: "timeout", +}; + +async function iframeTest(t, { source, target, expected }) { + // Allows running tests in parallel. + const uuid = token(); + + const targetUrl = preflightUrl(target); + targetUrl.searchParams.set("file", "iframed.html"); + targetUrl.searchParams.set("iframe-uuid", uuid); + + const sourceUrl = + resolveUrl("resources/iframer.html", sourceResolveOptions(source)); + sourceUrl.searchParams.set("url", targetUrl); + + const messagePromise = futureMessage({ + filter: (data) => data.uuid === uuid, + }); + const iframe = await appendIframe(t, document, sourceUrl); + + // The grandchild frame posts a message iff it loads successfully. + // There exists no interoperable way to check whether an iframe failed to + // load, so we use a timeout. + // See: https://github.com/whatwg/html/issues/125 + const result = await Promise.race([ + messagePromise.then((data) => data.message), + new Promise((resolve) => { + t.step_timeout(() => resolve("timeout"), 500 /* ms */); + }), + ]); + + assert_equals(result, expected); +} + +// Similar to `iframeTest`, but replaced iframes with fenced frames. +async function fencedFrameTest(t, { source, target, expected }) { + // Allows running tests in parallel. + const target_url = preflightUrl(target); + target_url.searchParams.set("file", "fenced-frame-local-network-access-target.https.html"); + target_url.searchParams.set("is-loaded-in-fenced-frame", true); + + const frame_loaded_key = token(); + const child_frame_target = generateURL(target_url, [frame_loaded_key]); + + const source_url = + resolveUrl("resources/fenced-frame-local-network-access.https.html", sourceResolveOptions(source)); + source_url.searchParams.set("fenced_frame_url", child_frame_target); + + const urn = await generateURNFromFledge(source_url, []); + attachFencedFrame(urn); + + // The grandchild fenced frame writes a value to the server iff it loads + // successfully. + const result = (expected == FrameTestResult.SUCCESS) ? + await nextValueFromServer(frame_loaded_key) : + await Promise.race([ + nextValueFromServer(frame_loaded_key), + new Promise((resolve) => { + t.step_timeout(() => resolve("timeout"), 10000 /* ms */); + }), + ]); + + assert_equals(result, expected); +} + +const iframeGrandparentTest = ({ + name, + grandparentServer, + child, + grandchild, + expected, +}) => promise_test_parallel(async (t) => { + // Allows running tests in parallel. + const grandparentUuid = token(); + const childUuid = token(); + const grandchildUuid = token(); + + const grandparentUrl = + resolveUrl("resources/executor.html", grandparentServer); + grandparentUrl.searchParams.set("executor-uuid", grandparentUuid); + + const childUrl = preflightUrl(child); + childUrl.searchParams.set("file", "executor.html"); + childUrl.searchParams.set("executor-uuid", childUuid); + + const grandchildUrl = preflightUrl(grandchild); + grandchildUrl.searchParams.set("file", "iframed.html"); + grandchildUrl.searchParams.set("iframe-uuid", grandchildUuid); + + const iframe = await appendIframe(t, document, grandparentUrl); + + const addChild = (url) => new Promise((resolve) => { + const child = document.createElement("iframe"); + child.src = url; + child.addEventListener("load", () => resolve(), { once: true }); + document.body.appendChild(child); + }); + + const grandparentCtx = new RemoteContext(grandparentUuid); + await grandparentCtx.execute_script(addChild, [childUrl]); + + // Add a blank grandchild frame inside the child. + // Apply a timeout to this step so that failures at this step do not block the + // execution of other tests. + const childCtx = new RemoteContext(childUuid); + await Promise.race([ + childCtx.execute_script(addChild, ["about:blank"]), + new Promise((resolve, reject) => t.step_timeout( + () => reject("timeout adding grandchild"), + 2000 /* ms */ + )), + ]); + + const messagePromise = futureMessage({ + filter: (data) => data.uuid === grandchildUuid, + }); + await grandparentCtx.execute_script((url) => { + const child = window.frames[0]; + const grandchild = child.frames[0]; + grandchild.location = url; + }, [grandchildUrl]); + + // The great-grandchild frame posts a message iff it loads successfully. + // There exists no interoperable way to check whether an iframe failed to + // load, so we use a timeout. + // See: https://github.com/whatwg/html/issues/125 + const result = await Promise.race([ + messagePromise.then((data) => data.message), + new Promise((resolve) => { + t.step_timeout(() => resolve("timeout"), 2000 /* ms */); + }), + ]); + + assert_equals(result, expected); +}, name); + +const WebsocketTestResult = { + SUCCESS: "open", + + // The code is a best guess. It is not yet entirely specified, so it may need + // to be changed in the future based on implementation experience. + FAILURE: "close: code 1006", +}; + +// Runs a websocket test. Attempts to open a websocket from `source` (in an +// iframe) to `target`, then checks that the result is as `expected`. +// +// Argument shape: +// +// { +// // Required. Passed to `sourceResolveOptions()`. +// source, +// +// // Required. +// target: { +// // Required. Target server. +// server, +// } +// +// // Required. Should be one of the values in `WebsocketTestResult`. +// expected, +// } +// +async function websocketTest(t, { source, target, expected }) { + const sourceUrl = + resolveUrl("resources/socket-opener.html", sourceResolveOptions(source)); + + const targetUrl = resolveUrl("/echo", target.server); + + const iframe = await appendIframe(t, document, sourceUrl); + + const reply = futureMessage(); + iframe.contentWindow.postMessage(targetUrl.href, "*"); + + assert_equals(await reply, expected); +} + +const WorkerScriptTestResult = { + SUCCESS: { loaded: true }, + FAILURE: { error: "unknown error" }, +}; + +function workerScriptUrl(target) { + const url = preflightUrl(target); + + url.searchParams.append("body", "postMessage({ loaded: true })") + url.searchParams.append("mime-type", "application/javascript") + + return url; +} + +async function workerScriptTest(t, { source, target, expected }) { + const sourceUrl = + resolveUrl("resources/worker-fetcher.html", sourceResolveOptions(source)); + + const targetUrl = workerScriptUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +async function nestedWorkerScriptTest(t, { source, target, expected }) { + const targetUrl = workerScriptUrl(target); + + const sourceUrl = resolveUrl( + "resources/worker-fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl); + + // Iframe must be same-origin with the parent worker. + const iframeUrl = new URL("worker-fetcher.html", sourceUrl); + + const iframe = await appendIframe(t, document, iframeUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +async function sharedWorkerScriptTest(t, { source, target, expected }) { + const sourceUrl = resolveUrl("resources/shared-worker-fetcher.html", + sourceResolveOptions(source)); + const targetUrl = preflightUrl(target); + targetUrl.searchParams.append( + "body", "onconnect = (e) => e.ports[0].postMessage({ loaded: true })") + targetUrl.searchParams.append("mime-type", "application/javascript") + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +// Results that may be expected in tests. +const WorkerFetchTestResult = { + SUCCESS: { status: 200, body: "success" }, + FAILURE: { error: "TypeError" }, +}; + +async function workerFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const sourceUrl = + resolveUrl("resources/fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl.href); + + const fetcherUrl = new URL("worker-fetcher.html", sourceUrl); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, status, body } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} + +async function workerBlobFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const fetcherUrl = resolveUrl( + 'resources/worker-blob-fetcher.html', sourceResolveOptions(source)); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, status, body } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} + +async function sharedWorkerFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const sourceUrl = + resolveUrl("resources/shared-fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl.href); + + const fetcherUrl = new URL("shared-worker-fetcher.html", sourceUrl); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, status, body } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} + +async function sharedWorkerBlobFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const fetcherUrl = resolveUrl( + 'resources/shared-worker-blob-fetcher.html', + sourceResolveOptions(source)); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, status, body } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} diff --git a/test/wpt/tests/fetch/private-network-access/resources/worker-blob-fetcher.html b/test/wpt/tests/fetch/private-network-access/resources/worker-blob-fetcher.html new file mode 100644 index 0000000..5a50271 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/worker-blob-fetcher.html @@ -0,0 +1,45 @@ + + +Worker Blob Fetcher + diff --git a/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.html b/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.html new file mode 100644 index 0000000..bd155a5 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.html @@ -0,0 +1,18 @@ + + +Worker Fetcher + diff --git a/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.js b/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.js new file mode 100644 index 0000000..aab49af --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/worker-fetcher.js @@ -0,0 +1,11 @@ +const url = new URL(self.location).searchParams.get("url"); +const worker = new Worker(url); + +// Relay messages from the worker to the parent frame. +worker.addEventListener("message", (evt) => { + self.postMessage(evt.data); +}); + +worker.addEventListener("error", (evt) => { + self.postMessage({ error: evt.message || "unknown error" }); +}); diff --git a/test/wpt/tests/fetch/private-network-access/resources/xhr-sender.html b/test/wpt/tests/fetch/private-network-access/resources/xhr-sender.html new file mode 100644 index 0000000..b131fa4 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/resources/xhr-sender.html @@ -0,0 +1,33 @@ + + +XHR Sender + diff --git a/test/wpt/tests/fetch/private-network-access/service-worker-background-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/service-worker-background-fetch.tentative.https.window.js new file mode 100644 index 0000000..6369b16 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/service-worker-background-fetch.tentative.https.window.js @@ -0,0 +1,142 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// Spec: https://wicg.github.io/background-fetch/ +// +// These tests check that background fetches from within `ServiceWorker` scripts +// are not subject to Private Network Access checks. + +// Results that may be expected in tests. +const TestResult = { + SUCCESS: { ok: true, body: "success", result: "success", failureReason: "" }, +}; + +async function makeTest(t, { source, target, expected }) { + const scriptUrl = + resolveUrl("resources/service-worker.js", sourceResolveOptions(source)); + + const bridgeUrl = new URL("service-worker-bridge.html", scriptUrl); + + const targetUrl = preflightUrl(target); + + const iframe = await appendIframe(t, document, bridgeUrl); + + const request = (message) => { + const reply = futureMessage(); + iframe.contentWindow.postMessage(message, "*"); + return reply; + }; + + { + const { error, loaded } = await request({ + action: "register", + url: scriptUrl.href, + }); + + assert_equals(error, undefined, "register error"); + assert_true(loaded, "response loaded"); + } + + { + const { error, state } = await request({ + action: "set-permission", + name: "background-fetch", + state: "granted", + }); + + assert_equals(error, undefined, "set permission error"); + assert_equals(state, "granted", "permission state"); + } + + { + const { error, result, failureReason, ok, body } = await request({ + action: "background-fetch", + url: targetUrl.href, + }); + + assert_equals(error, expected.error, "error"); + assert_equals(failureReason, expected.failureReason, "fetch failure reason"); + assert_equals(result, expected.result, "fetch result"); + assert_equals(ok, expected.ok, "response ok"); + assert_equals(body, expected.body, "response body"); + } +} + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "private to local: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: TestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "public to local: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "public to private: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: TestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "treat-as-public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/service-worker-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/service-worker-fetch.tentative.https.window.js new file mode 100644 index 0000000..cb6d1f7 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/service-worker-fetch.tentative.https.window.js @@ -0,0 +1,235 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: script=/common/subset-tests.js +// META: variant=?1-8 +// META: variant=?9-last +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `ServiceWorker` scripts are +// subject to Private Network Access checks, just like fetches from within +// documents. + +// Results that may be expected in tests. +const TestResult = { + SUCCESS: { ok: true, body: "success" }, + FAILURE: { error: "TypeError" }, +}; + +async function makeTest(t, { source, target, expected }) { + const bridgeUrl = resolveUrl( + "resources/service-worker-bridge.html", + sourceResolveOptions({ server: source.server })); + + const scriptUrl = + resolveUrl("resources/service-worker.js", sourceResolveOptions(source)); + + const realTargetUrl = preflightUrl(target); + + // Fetch a URL within the service worker's scope, but tell it which URL to + // really fetch. + const targetUrl = new URL("service-worker-proxy", scriptUrl); + targetUrl.searchParams.append("proxied-url", realTargetUrl.href); + + const iframe = await appendIframe(t, document, bridgeUrl); + + const request = (message) => { + const reply = futureMessage(); + iframe.contentWindow.postMessage(message, "*"); + return reply; + }; + + { + const { error, loaded } = await request({ + action: "register", + url: scriptUrl.href, + }); + + assert_equals(error, undefined, "register error"); + assert_true(loaded, "response loaded"); + } + + try { + const { controlled, numControllerChanges } = await request({ + action: "wait", + numControllerChanges: 1, + }); + + assert_equals(numControllerChanges, 1, "controller change"); + assert_true(controlled, "bridge script is controlled"); + + const { error, ok, body } = await request({ + action: "fetch", + url: targetUrl.href, + }); + + assert_equals(error, expected.error, "fetch error"); + assert_equals(ok, expected.ok, "response ok"); + assert_equals(body, expected.body, "response body"); + } finally { + // Always unregister the service worker. + const { error, unregistered } = await request({ + action: "unregister", + scope: new URL("./", scriptUrl).href, + }); + + assert_equals(error, undefined, "unregister error"); + assert_true(unregistered, "unregistered"); + } +} + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "local to local: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.FAILURE, +}), "private to local: failed preflight."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: TestResult.SUCCESS, +}), "private to local: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: TestResult.SUCCESS, +}), "private to private: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.FAILURE, +}), "public to local: failed preflight."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: TestResult.SUCCESS, +}), "public to local: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.FAILURE, +}), "public to private: failed preflight."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: TestResult.SUCCESS, +}), "public to private: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: TestResult.SUCCESS, +}), "public to public: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.FAILURE, +}), "treat-as-public to local: failed preflight."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: TestResult.SUCCESS, +}), "treat-as-public to local: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "treat-as-public to local (same-origin): no preflight required."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.FAILURE, +}), "treat-as-public to private: failed preflight."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: TestResult.SUCCESS, +}), "treat-as-public to private: success."); + +subsetTest(promise_test, t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: TestResult.SUCCESS, +}), "treat-as-public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/service-worker-update.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/service-worker-update.tentative.https.window.js new file mode 100644 index 0000000..4882d23 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/service-worker-update.tentative.https.window.js @@ -0,0 +1,106 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that `ServiceWorker` script update fetches are exempt from +// Private Network Access checks because they are always same-origin and the +// origin is potentially trustworthy. The client of the fetch, for PNA purposes, +// is taken to be the previous script. +// +// The tests is carried out by instantiating a service worker from a resource +// that carries the `Content-Security-Policy: treat-as-public-address` header, +// such that the registration is placed in the public IP address space. When +// the script is fetched for an update, the client is thus considered public, +// yet the same-origin fetch observes that the server's IP endpoint is not +// necessarily in the public IP address space. +// +// See also: worker.https.window.js + +// Results that may be expected in tests. +const TestResult = { + SUCCESS: { updated: true }, + FAILURE: { error: "TypeError" }, +}; + +async function makeTest(t, { target, expected }) { + // The bridge must be same-origin with the service worker script. + const bridgeUrl = resolveUrl( + "resources/service-worker-bridge.html", + sourceResolveOptions({ server: target.server })); + + const scriptUrl = preflightUrl(target); + scriptUrl.searchParams.append("treat-as-public-once", token()); + scriptUrl.searchParams.append("mime-type", "application/javascript"); + scriptUrl.searchParams.append("file", "service-worker.js"); + scriptUrl.searchParams.append("random-js-prefix", true); + + const iframe = await appendIframe(t, document, bridgeUrl); + + const request = (message) => { + const reply = futureMessage(); + iframe.contentWindow.postMessage(message, "*"); + return reply; + }; + + { + const { error, loaded } = await request({ + action: "register", + url: scriptUrl.href, + }); + + assert_equals(error, undefined, "register error"); + assert_true(loaded, "response loaded"); + } + + try { + let { controlled, numControllerChanges } = await request({ + action: "wait", + numControllerChanges: 1, + }); + + assert_equals(numControllerChanges, 1, "controller change"); + assert_true(controlled, "bridge script is controlled"); + + const { error, updated } = await request({ action: "update" }); + + assert_equals(error, expected.error, "update error"); + assert_equals(updated, expected.updated, "registration updated"); + + // Stop here if we do not expect the update to succeed. + if (!expected.updated) { + return; + } + + ({ controlled, numControllerChanges } = await request({ + action: "wait", + numControllerChanges: 2, + })); + + assert_equals(numControllerChanges, 2, "controller change"); + assert_true(controlled, "bridge script still controlled"); + } finally { + const { error, unregistered } = await request({ + action: "unregister", + scope: new URL("./", scriptUrl).href, + }); + + assert_equals(error, undefined, "unregister error"); + assert_true(unregistered, "unregistered"); + } +} + +promise_test(t => makeTest(t, { + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "update public to local: success."); + +promise_test(t => makeTest(t, { + target: { server: Server.HTTPS_PRIVATE }, + expected: TestResult.SUCCESS, +}), "update public to private: success."); + +promise_test(t => makeTest(t, { + target: { server: Server.HTTPS_PUBLIC }, + expected: TestResult.SUCCESS, +}), "update public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/service-worker.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/service-worker.tentative.https.window.js new file mode 100644 index 0000000..046f662 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/service-worker.tentative.https.window.js @@ -0,0 +1,84 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that initial `ServiceWorker` script fetches are exempt from +// Private Network Access checks because they are always same-origin and the +// origin is potentially trustworthy. +// +// See also: worker.https.window.js + +// Results that may be expected in tests. +const TestResult = { + SUCCESS: { + register: { loaded: true }, + unregister: { unregistered: true }, + }, + FAILURE: { + register: { error: "TypeError" }, + unregister: { unregistered: false, error: "no registration" }, + }, +}; + +async function makeTest(t, { source, target, expected }) { + const sourceUrl = resolveUrl("resources/service-worker-bridge.html", + sourceResolveOptions(source)); + + const targetUrl = preflightUrl(target); + targetUrl.searchParams.append("body", "undefined"); + targetUrl.searchParams.append("mime-type", "application/javascript"); + + const scope = resolveUrl(`resources/${token()}`, {...target.server}).href; + + const iframe = await appendIframe(t, document, sourceUrl); + + { + const reply = futureMessage(); + const message = { + action: "register", + url: targetUrl.href, + options: { scope }, + }; + iframe.contentWindow.postMessage(message, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.register.error, "register error"); + assert_equals(loaded, expected.register.loaded, "response loaded"); + } + + { + const reply = futureMessage(); + iframe.contentWindow.postMessage({ action: "unregister", scope }, "*"); + + const { error, unregistered } = await reply; + assert_equals(error, expected.unregister.error, "unregister error"); + assert_equals( + unregistered, expected.unregister.unregistered, "worker unregistered"); + } +} + +promise_test(t => makeTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: TestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => makeTest(t, { + source: { + server: Server.HTTPS_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_PRIVATE }, + expected: TestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => makeTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: TestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.https.window.js new file mode 100644 index 0000000..269abb7 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.https.window.js @@ -0,0 +1,168 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `SharedWorker` scripts that are +// loaded from blob URLs are subject to Private Network Access checks, just like +// fetches from within documents. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: shared-worker-blob-fetch.window.js + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failed preflight."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failed preflight."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failed preflight."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to private: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failed preflight."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to local (same-origin): no preflight required."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failed preflight."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.window.js new file mode 100644 index 0000000..d430ea7 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker-blob-fetch.tentative.window.js @@ -0,0 +1,173 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `SharedWorker` scripts that are +// loaded from blob URLs are subject to Private Network Access checks, just like +// fetches from within documents. +// +// This file covers only those tests that must execute in a non-secure context. +// Other tests are defined in: shared-worker-blob-fetch.https.window.js + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { preflight: PreflightBehavior.optionalSuccess(token()) }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + +// The following tests verify that workers served over HTTPS are not allowed to +// make private network requests because they are not secure contexts. + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTP_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local https to local: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private https to local: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to local: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local https to local https: success."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private https to local https: failure."); + +promise_test(t => sharedWorkerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to local https: failure."); diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.https.window.js new file mode 100644 index 0000000..e5f2b94 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.https.window.js @@ -0,0 +1,167 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `SharedWorker` scripts are subject +// to Private Network Access checks, just like fetches from within documents. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: shared-worker-fetch.window.js + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failed preflight."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failed preflight."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to private: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failed preflight."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to local (same-origin): no preflight required."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failed preflight."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.window.js new file mode 100644 index 0000000..9bc1a89 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker-fetch.tentative.window.js @@ -0,0 +1,154 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `SharedWorker` scripts are subject +// to Private Network Access checks, just like fetches from within documents. +// +// This file covers only those tests that must execute in a non-secure context. +// Other tests are defined in: shared-worker-fetch.https.window.js + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { preflight: PreflightBehavior.optionalSuccess(token()) }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + +// The following tests verify that workers served over HTTPS are not allowed to +// make private network requests because they are not secure contexts. + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local https to local: success."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private https to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to local: failure."); + +promise_test(t => sharedWorkerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to private: failure."); diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.https.window.js new file mode 100644 index 0000000..24ae108 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.https.window.js @@ -0,0 +1,34 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests mirror `Worker` tests, except using `SharedWorker`. +// See also: worker.https.window.js +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: shared-worker.window.js + +promise_test(t => sharedWorkerScriptTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerScriptTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => sharedWorkerScriptTest(t, { + source: { + server: Server.HTTPS_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerScriptTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => sharedWorkerScriptTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.window.js b/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.window.js new file mode 100644 index 0000000..ffa8a36 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/shared-worker.tentative.window.js @@ -0,0 +1,34 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests mirror `Worker` tests, except using `SharedWorker`. +// See also: shared-worker.window.js +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: shared-worker.https.window.js + +promise_test(t => sharedWorkerScriptTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => sharedWorkerScriptTest(t, { + source: { + server: Server.HTTP_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => sharedWorkerScriptTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/websocket.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/websocket.tentative.https.window.js new file mode 100644 index 0000000..0731896 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/websocket.tentative.https.window.js @@ -0,0 +1,40 @@ +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that websocket connections behave similarly to fetches. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: websocket.https.window.js + +setup(() => { + // Making sure we are in a secure context, as expected. + assert_true(window.isSecureContext); +}); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.WSS_LOCAL }, + expected: WebsocketTestResult.SUCCESS, +}), "local to local: websocket success."); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.WSS_LOCAL }, + expected: WebsocketTestResult.SUCCESS, +}), "private to local: websocket success."); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.WSS_LOCAL }, + expected: WebsocketTestResult.SUCCESS, +}), "public to local: websocket success."); + +promise_test(t => websocketTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.WSS_LOCAL }, + expected: WebsocketTestResult.SUCCESS, +}), "treat-as-public to local: websocket success."); diff --git a/test/wpt/tests/fetch/private-network-access/websocket.tentative.window.js b/test/wpt/tests/fetch/private-network-access/websocket.tentative.window.js new file mode 100644 index 0000000..a44cfae --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/websocket.tentative.window.js @@ -0,0 +1,40 @@ +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch + +// These tests verify that websocket connections behave similarly to fetches. +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: websocket.https.window.js + +setup(() => { + // Making sure we are in a non secure context, as expected. + assert_false(window.isSecureContext); +}); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.WS_LOCAL }, + expected: WebsocketTestResult.SUCCESS, +}), "local to local: websocket success."); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.WS_LOCAL }, + expected: WebsocketTestResult.FAILURE, +}), "private to local: websocket failure."); + +promise_test(t => websocketTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.WS_LOCAL }, + expected: WebsocketTestResult.FAILURE, +}), "public to local: websocket failure."); + +promise_test(t => websocketTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.WS_LOCAL }, + expected: WebsocketTestResult.FAILURE, +}), "treat-as-public to local: websocket failure."); diff --git a/test/wpt/tests/fetch/private-network-access/worker-blob-fetch.tentative.window.js b/test/wpt/tests/fetch/private-network-access/worker-blob-fetch.tentative.window.js new file mode 100644 index 0000000..e119746 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/worker-blob-fetch.tentative.window.js @@ -0,0 +1,155 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `Worker` scripts loaded from blob +// URLs are subject to Private Network Access checks, just like fetches from +// within documents. +// +// This file covers only those tests that must execute in a non-secure context. +// Other tests are defined in: worker-blob-fetch.https.window.js + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => workerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { preflight: PreflightBehavior.optionalSuccess(token()) }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + +// The following tests verify that workers served over HTTPS are not allowed to +// make private network requests because they are not secure contexts. + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local https to local https: success."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private https to local https: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to private https: failure."); + +promise_test(t => workerBlobFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to local https: failure."); diff --git a/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.https.window.js new file mode 100644 index 0000000..89e0c3c --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.https.window.js @@ -0,0 +1,151 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `Worker` scripts are subject to +// Private Network Access checks, just like fetches from within documents. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: worker-fetch.window.js + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failed preflight."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to local: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failed preflight."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to local: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failed preflight."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to private: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failed preflight."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { preflight: PreflightBehavior.optionalSuccess(token()) }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failed preflight."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.window.js b/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.window.js new file mode 100644 index 0000000..4d6b12f --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/worker-fetch.tentative.window.js @@ -0,0 +1,154 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that fetches from within `Worker` scripts are subject to +// Private Network Access checks, just like fetches from within documents. +// +// This file covers only those tests that must execute in a non-secure context. +// Other tests are defined in: worker-fetch.https.window.js + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local to local: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerFetchTestResult.SUCCESS, +}), "private to private: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerFetchTestResult.SUCCESS, +}), "public to public: success."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { preflight: PreflightBehavior.optionalSuccess(token()) }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => workerFetchTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "treat-as-public to public: success."); + +// The following tests verify that workers served over HTTPS are not allowed to +// make private network requests because they are not secure contexts. + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.SUCCESS, +}), "local https to local https: success."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "private https to local https: failure."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to private https: failure."); + +promise_test(t => workerFetchTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: WorkerFetchTestResult.FAILURE, +}), "public https to local https: failure."); diff --git a/test/wpt/tests/fetch/private-network-access/worker.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/worker.tentative.https.window.js new file mode 100644 index 0000000..a0f1931 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/worker.tentative.https.window.js @@ -0,0 +1,37 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that initial `Worker` script fetches in secure contexts are +// exempt from Private Network Access checks because workers can only be fetched +// same-origin and the origin is potentially trustworthy. The only way to test +// this is using the `treat-as-public` CSP directive to artificially place the +// parent document in the `public` IP address space. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: worker.window.js + +promise_test(t => workerScriptTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: WorkerScriptTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => workerScriptTest(t, { + source: { + server: Server.HTTPS_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_PRIVATE }, + expected: WorkerScriptTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => workerScriptTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/worker.tentative.window.js b/test/wpt/tests/fetch/private-network-access/worker.tentative.window.js new file mode 100644 index 0000000..118c099 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/worker.tentative.window.js @@ -0,0 +1,37 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests check that initial `Worker` script fetches are subject to Private +// Network Access checks, just like a regular `fetch()`. The main difference is +// that workers can only be fetched same-origin, so the only way to test this +// is using the `treat-as-public` CSP directive to artificially place the parent +// document in the `public` IP address space. +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: worker.https.window.js + +promise_test(t => workerScriptTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTP_LOCAL }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to local: failure."); + +promise_test(t => workerScriptTest(t, { + source: { + server: Server.HTTP_PRIVATE, + treatAsPublic: true, + }, + target: { server: Server.HTTP_PRIVATE }, + expected: WorkerScriptTestResult.FAILURE, +}), "treat-as-public to private: failure."); + +promise_test(t => workerScriptTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: WorkerScriptTestResult.SUCCESS, +}), "public to public: success."); diff --git a/test/wpt/tests/fetch/private-network-access/xhr-from-treat-as-public.tentative.https.window.js b/test/wpt/tests/fetch/private-network-access/xhr-from-treat-as-public.tentative.https.window.js new file mode 100644 index 0000000..3aae305 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/xhr-from-treat-as-public.tentative.https.window.js @@ -0,0 +1,83 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests verify that documents fetched from the `local` address space yet +// carrying the `treat-as-public-address` CSP directive are treated as if they +// had been fetched from the `public` address space. + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.FAILURE, +}), "treat-as-public to local: failed preflight."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.OTHER_HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "treat-as-public to local: success."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { server: Server.HTTPS_LOCAL }, + expected: XhrTestResult.SUCCESS, +}), "treat-as-public to local (same-origin): no preflight required."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.FAILURE, +}), "treat-as-public to private: failed preflight."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "treat-as-public to private: success."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTPS_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "treat-as-public to public: no preflight required."); diff --git a/test/wpt/tests/fetch/private-network-access/xhr.https.tentative.window.js b/test/wpt/tests/fetch/private-network-access/xhr.https.tentative.window.js new file mode 100644 index 0000000..4dc5da9 --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/xhr.https.tentative.window.js @@ -0,0 +1,142 @@ +// META: script=/common/subset-tests-by-key.js +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// META: variant=?include=from-local +// META: variant=?include=from-private +// META: variant=?include=from-public +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests mirror fetch.https.window.js, but use `XmlHttpRequest` instead of +// `fetch()` to perform subresource fetches. Preflights are tested less +// extensively due to coverage being already provided by `fetch()`. +// +// This file covers only those tests that must execute in a secure context. +// Other tests are defined in: xhr.window.js + +setup(() => { + // Making sure we are in a secure context, as expected. + assert_true(window.isSecureContext); +}); + +// Source: secure local context. +// +// All fetches unaffected by Private Network Access. + +subsetTestByKey("from-local", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { server: Server.HTTPS_LOCAL }, + expected: XhrTestResult.SUCCESS, +}), "local to local: no preflight required."); + +subsetTestByKey("from-local", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "local to private: no preflight required."); + +subsetTestByKey("from-local", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "local to public: no preflight required."); + +// Source: private secure context. +// +// Fetches to the local address space require a successful preflight response +// carrying a PNA-specific header. + +subsetTestByKey("from-private", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.FAILURE, +}), "private to local: failed preflight."); + +subsetTestByKey("from-private", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "private to local: success."); + +subsetTestByKey("from-private", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { server: Server.HTTPS_PRIVATE }, + expected: XhrTestResult.SUCCESS, +}), "private to private: no preflight required."); + +subsetTestByKey("from-private", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "private to public: no preflight required."); + +// Source: public secure context. +// +// Fetches to the local and private address spaces require a successful +// preflight response carrying a PNA-specific header. + +subsetTestByKey("from-public", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.FAILURE, +}), "public to local: failed preflight."); + +subsetTestByKey("from-public", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "public to local: success."); + +subsetTestByKey("from-public", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.FAILURE, +}), "public to private: failed preflight."); + +subsetTestByKey("from-public", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.success(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "public to private: success."); + +subsetTestByKey("from-public", promise_test, t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { server: Server.HTTPS_PUBLIC }, + expected: XhrTestResult.SUCCESS, +}), "public to public: no preflight required."); diff --git a/test/wpt/tests/fetch/private-network-access/xhr.tentative.window.js b/test/wpt/tests/fetch/private-network-access/xhr.tentative.window.js new file mode 100644 index 0000000..fa307dc --- /dev/null +++ b/test/wpt/tests/fetch/private-network-access/xhr.tentative.window.js @@ -0,0 +1,195 @@ +// META: script=/common/utils.js +// META: script=resources/support.sub.js +// +// Spec: https://wicg.github.io/private-network-access/#integration-fetch +// +// These tests mirror fetch.window.js, but use `XmlHttpRequest` instead of +// `fetch()` to perform subresource fetches. +// +// This file covers only those tests that must execute in a non secure context. +// Other tests are defined in: xhr.https.window.js + +setup(() => { + // Making sure we are in a non secure context, as expected. + assert_false(window.isSecureContext); +}); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { server: Server.HTTP_LOCAL }, + expected: XhrTestResult.SUCCESS, +}), "local to local: no preflight required."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "local to private: no preflight required."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_LOCAL }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "local to public: no preflight required."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "private to local: failure."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { server: Server.HTTP_PRIVATE }, + expected: XhrTestResult.SUCCESS, +}), "private to private: no preflight required."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PRIVATE }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "private to public: no preflight required."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "public to local: failure."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "public to private: failure."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTP_PUBLIC }, + target: { server: Server.HTTP_PUBLIC }, + expected: XhrTestResult.SUCCESS, +}), "public to public: no preflight required."); + +// These tests verify that documents fetched from the `local` address space yet +// carrying the `treat-as-public-address` CSP directive are treated as if they +// had been fetched from the `public` address space. + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "treat-as-public-address to local: failure."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "treat-as-public-address to private: failure."); + +promise_test(t => xhrTest(t, { + source: { + server: Server.HTTP_LOCAL, + treatAsPublic: true, + }, + target: { + server: Server.HTTP_PUBLIC, + behavior: { response: ResponseBehavior.allowCrossOrigin() }, + }, + expected: XhrTestResult.SUCCESS, +}), "treat-as-public-address to public: no preflight required."); + +// These tests verify that HTTPS iframes embedded in an HTTP top-level document +// cannot fetch subresources from less-public address spaces. Indeed, even +// though the iframes have HTTPS origins, they are non-secure contexts because +// their parent is a non-secure context. + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTPS_LOCAL }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.SUCCESS, +}), "local https to local: success."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTPS_PRIVATE }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "private https to local: failure."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_LOCAL, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "public https to local: failure."); + +promise_test(t => xhrTest(t, { + source: { server: Server.HTTPS_PUBLIC }, + target: { + server: Server.HTTPS_PRIVATE, + behavior: { + preflight: PreflightBehavior.optionalSuccess(token()), + response: ResponseBehavior.allowCrossOrigin(), + }, + }, + expected: XhrTestResult.FAILURE, +}), "public https to private: failure."); diff --git a/test/wpt/tests/fetch/range/blob.any.js b/test/wpt/tests/fetch/range/blob.any.js new file mode 100644 index 0000000..7bcd4b9 --- /dev/null +++ b/test/wpt/tests/fetch/range/blob.any.js @@ -0,0 +1,233 @@ +// META: script=/common/utils.js + +const supportedBlobRange = [ + { + name: "A simple blob range request.", + data: ["A simple Hello, World! example"], + type: "text/plain", + range: "bytes=9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "A blob range request with no type.", + data: ["A simple Hello, World! example"], + type: undefined, + range: "bytes=9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "A blob range request with no end.", + data: ["Range with no end"], + type: "text/plain", + range: "bytes=11-", + content_length: 6, + content_range: "bytes 11-16/17", + result: "no end", + }, + { + name: "A blob range request with no start.", + data: ["Range with no start"], + type: "text/plain", + range: "bytes=-8", + content_length: 8, + content_range: "bytes 11-18/19", + result: "no start", + }, + { + name: "A simple blob range request with whitespace.", + data: ["A simple Hello, World! example"], + type: "text/plain", + range: "bytes= \t9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "Blob content with short content and a large range end", + data: ["Not much here"], + type: "text/plain", + range: "bytes=4-100000000000", + content_length: 9, + content_range: "bytes 4-12/13", + result: "much here", + }, + { + name: "Blob content with short content and a range end matching content length", + data: ["Not much here"], + type: "text/plain", + range: "bytes=4-13", + content_length: 9, + content_range: "bytes 4-12/13", + result: "much here", + }, + { + name: "Blob range with whitespace before and after hyphen", + data: ["Valid whitespace #1"], + type: "text/plain", + range: "bytes=5 - 10", + content_length: 6, + content_range: "bytes 5-10/19", + result: " white", + }, + { + name: "Blob range with whitespace after hyphen", + data: ["Valid whitespace #2"], + type: "text/plain", + range: "bytes=-\t 5", + content_length: 5, + content_range: "bytes 14-18/19", + result: "ce #2", + }, + { + name: "Blob range with whitespace around equals sign", + data: ["Valid whitespace #3"], + type: "text/plain", + range: "bytes \t =\t 6-", + content_length: 13, + content_range: "bytes 6-18/19", + result: "whitespace #3", + }, +]; + +const unsupportedBlobRange = [ + { + name: "Blob range with no value", + data: ["Blob range should have a value"], + type: "text/plain", + range: "", + }, + { + name: "Blob range with incorrect range header", + data: ["A"], + type: "text/plain", + range: "byte=0-" + }, + { + name: "Blob range with incorrect range header #2", + data: ["A"], + type: "text/plain", + range: "bytes" + }, + { + name: "Blob range with incorrect range header #3", + data: ["A"], + type: "text/plain", + range: "bytes\t \t" + }, + { + name: "Blob range request with multiple range values", + data: ["Multiple ranges are not currently supported"], + type: "text/plain", + range: "bytes=0-5,15-", + }, + { + name: "Blob range request with multiple range values and whitespace", + data: ["Multiple ranges are not currently supported"], + type: "text/plain", + range: "bytes=0-5, 15-", + }, + { + name: "Blob range request with trailing comma", + data: ["Range with invalid trailing comma"], + type: "text/plain", + range: "bytes=0-5,", + }, + { + name: "Blob range with no start or end", + data: ["Range with no start or end"], + type: "text/plain", + range: "bytes=-", + }, + { + name: "Blob range request with short range end", + data: ["Range end should be greater than range start"], + type: "text/plain", + range: "bytes=10-5", + }, + { + name: "Blob range start should be an ASCII digit", + data: ["Range start must be an ASCII digit"], + type: "text/plain", + range: "bytes=x-5", + }, + { + name: "Blob range should have a dash", + data: ["Blob range should have a dash"], + type: "text/plain", + range: "bytes=5", + }, + { + name: "Blob range end should be an ASCII digit", + data: ["Range end must be an ASCII digit"], + type: "text/plain", + range: "bytes=5-x", + }, + { + name: "Blob range should include '-'", + data: ["Range end must include '-'"], + type: "text/plain", + range: "bytes=x", + }, + { + name: "Blob range should include '='", + data: ["Range end must include '='"], + type: "text/plain", + range: "bytes 5-", + }, + { + name: "Blob range should include 'bytes='", + data: ["Range end must include 'bytes='"], + type: "text/plain", + range: "5-", + }, + { + name: "Blob content with short content and a large range start", + data: ["Not much here"], + type: "text/plain", + range: "bytes=100000-", + }, + { + name: "Blob content with short content and a range start matching the content length", + data: ["Not much here"], + type: "text/plain", + range: "bytes=13-", + }, +]; + +supportedBlobRange.forEach(({ name, data, type, range, content_length, content_range, result }) => { + promise_test(async t => { + const blob = new Blob(data, { "type" : type }); + const blobURL = URL.createObjectURL(blob); + t.add_cleanup(() => URL.revokeObjectURL(blobURL)); + const resp = await fetch(blobURL, { + "headers": { + "Range": range + } + }); + assert_equals(resp.status, 206, "HTTP status is 206"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), type || "", "Content-Type is " + resp.headers.get("Content-Type")); + assert_equals(resp.headers.get("Content-Length"), content_length.toString(), "Content-Length is " + resp.headers.get("Content-Length")); + assert_equals(resp.headers.get("Content-Range"), content_range, "Content-Range is " + resp.headers.get("Content-Range")); + const text = await resp.text(); + assert_equals(text, result, "Response's body is correct"); + }, name); +}); + +unsupportedBlobRange.forEach(({ name, data, type, range }) => { + promise_test(t => { + const blob = new Blob(data, { "type" : type }); + const blobURL = URL.createObjectURL(blob); + t.add_cleanup(() => URL.revokeObjectURL(blobURL)); + const promise = fetch(blobURL, { + "headers": { + "Range": range + } + }); + return promise_rejects_js(t, TypeError, promise); + }, name); +}); diff --git a/test/wpt/tests/fetch/range/data.any.js b/test/wpt/tests/fetch/range/data.any.js new file mode 100644 index 0000000..22ef11e --- /dev/null +++ b/test/wpt/tests/fetch/range/data.any.js @@ -0,0 +1,29 @@ +// META: script=/common/utils.js + +promise_test(async () => { + return fetch("data:text/plain;charset=US-ASCII,paddingHello%2C%20World%21padding", { + "method": "GET", + "Range": "bytes=13-26" + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), "text/plain;charset=US-ASCII", "Content-Type is " + resp.headers.get("Content-Type")); + return resp.text(); + }).then(function(text) { + assert_equals(text, 'paddingHello, World!padding', "Response's body ignores range"); + }); +}, "data: URL and Range header"); + +promise_test(async () => { + return fetch("data:text/plain;charset=US-ASCII,paddingHello%2C%20paddingWorld%21padding", { + "method": "GET", + "Range": "bytes=7-14,21-27" + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), "text/plain;charset=US-ASCII", "Content-Type is " + resp.headers.get("Content-Type")); + return resp.text(); + }).then(function(text) { + assert_equals(text, 'paddingHello, paddingWorld!padding', "Response's body ignores range"); + }); +}, "data: URL and Range header with multiple ranges"); diff --git a/test/wpt/tests/fetch/range/general.any.js b/test/wpt/tests/fetch/range/general.any.js new file mode 100644 index 0000000..64b225a --- /dev/null +++ b/test/wpt/tests/fetch/range/general.any.js @@ -0,0 +1,140 @@ +// META: timeout=long +// META: global=window,worker +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js + +// Helpers that return headers objects with a particular guard +function headersGuardNone(fill) { + if (fill) return new Headers(fill); + return new Headers(); +} + +function headersGuardResponse(fill) { + const opts = {}; + if (fill) opts.headers = fill; + return new Response('', opts).headers; +} + +function headersGuardRequest(fill) { + const opts = {}; + if (fill) opts.headers = fill; + return new Request('./', opts).headers; +} + +function headersGuardRequestNoCors(fill) { + const opts = { mode: 'no-cors' }; + if (fill) opts.headers = fill; + return new Request('./', opts).headers; +} + +const headerGuardTypes = [ + ['none', headersGuardNone], + ['response', headersGuardResponse], + ['request', headersGuardRequest] +]; + +for (const [guardType, createHeaders] of headerGuardTypes) { + test(() => { + // There are three ways to set headers. + // Filling, appending, and setting. Test each: + let headers = createHeaders({ Range: 'foo' }); + assert_equals(headers.get('Range'), 'foo'); + + headers = createHeaders(); + headers.append('Range', 'foo'); + assert_equals(headers.get('Range'), 'foo'); + + headers = createHeaders(); + headers.set('Range', 'foo'); + assert_equals(headers.get('Range'), 'foo'); + }, `Range header setting allowed for guard type: ${guardType}`); +} + +test(() => { + let headers = headersGuardRequestNoCors({ Range: 'foo' }); + assert_false(headers.has('Range')); + + headers = headersGuardRequestNoCors(); + headers.append('Range', 'foo'); + assert_false(headers.has('Range')); + + headers = headersGuardRequestNoCors(); + headers.set('Range', 'foo'); + assert_false(headers.has('Range')); +}, `Privileged header not allowed for guard type: request-no-cors`); + +promise_test(async () => { + const wavURL = new URL('resources/long-wav.py', location); + const stashTakeURL = new URL('resources/stash-take.py', location); + + function changeToken() { + const stashToken = token(); + wavURL.searchParams.set('accept-encoding-key', stashToken); + stashTakeURL.searchParams.set('key', stashToken); + } + + const rangeHeaders = [ + 'bytes=0-10', + 'foo=0-10', + 'foo', + '' + ]; + + for (const rangeHeader of rangeHeaders) { + changeToken(); + + await fetch(wavURL, { + headers: { Range: rangeHeader } + }); + + const response = await fetch(stashTakeURL); + + assert_regexp_match(await response.json(), + /.*\bidentity\b.*/, + `Expect identity accept-encoding if range header is ${JSON.stringify(rangeHeader)}`); + } +}, `Fetch with range header will be sent with Accept-Encoding: identity`); + +promise_test(async () => { + const wavURL = new URL(get_host_info().HTTP_REMOTE_ORIGIN + '/fetch/range/resources/long-wav.py'); + const stashTakeURL = new URL('resources/stash-take.py', location); + + function changeToken() { + const stashToken = token(); + wavURL.searchParams.set('accept-encoding-key', stashToken); + stashTakeURL.searchParams.set('key', stashToken); + } + + const rangeHeaders = [ + 'bytes=10-9', + 'bytes=-0', + 'bytes=0000000000000000000000000000000000000000000000000000000000011-0000000000000000000000000000000000000000000000000000000000111', + ]; + + for (const rangeHeader of rangeHeaders) { + changeToken(); + await fetch(wavURL, { headers: { Range : rangeHeader} }).then(() => { throw "loaded with range header " + rangeHeader }, () => { }); + } +}, `Cross Origin Fetch with non safe range header`); + +promise_test(async () => { + const wavURL = new URL(get_host_info().HTTP_REMOTE_ORIGIN + '/fetch/range/resources/long-wav.py'); + const stashTakeURL = new URL('resources/stash-take.py', location); + + function changeToken() { + const stashToken = token(); + wavURL.searchParams.set('accept-encoding-key', stashToken); + stashTakeURL.searchParams.set('key', stashToken); + } + + const rangeHeaders = [ + 'bytes=0-10', + 'bytes=0-', + 'bytes=00000000000000000000000000000000000000000000000000000000011-00000000000000000000000000000000000000000000000000000000000111', + ]; + + for (const rangeHeader of rangeHeaders) { + changeToken(); + await fetch(wavURL, { headers: { Range: rangeHeader } }).then(() => { }, () => { throw "failed load with range header " + rangeHeader }); + } +}, `Cross Origin Fetch with safe range header`); diff --git a/test/wpt/tests/fetch/range/general.window.js b/test/wpt/tests/fetch/range/general.window.js new file mode 100644 index 0000000..afe80d6 --- /dev/null +++ b/test/wpt/tests/fetch/range/general.window.js @@ -0,0 +1,29 @@ +// META: script=resources/utils.js +// META: script=/common/utils.js + +const onload = new Promise(r => window.addEventListener('load', r)); + +// It's weird that browsers do this, but it should continue to work. +promise_test(async t => { + await loadScript('resources/partial-script.py?pretend-offset=90000'); + assert_true(self.scriptExecuted); +}, `Script executed from partial response`); + +promise_test(async () => { + const wavURL = new URL('resources/long-wav.py', location); + const stashTakeURL = new URL('resources/stash-take.py', location); + const stashToken = token(); + wavURL.searchParams.set('accept-encoding-key', stashToken); + stashTakeURL.searchParams.set('key', stashToken); + + // The testing framework waits for window onload. If the audio element + // is appended before onload, it extends it, and the test times out. + await onload; + + const audio = appendAudio(document, wavURL); + await new Promise(r => audio.addEventListener('progress', r)); + audio.remove(); + + const response = await fetch(stashTakeURL); + assert_equals(await response.json(), 'identity', `Expect identity accept-encoding on media request`); +}, `Fetch with range header will be sent with Accept-Encoding: identity`); diff --git a/test/wpt/tests/fetch/range/non-matching-range-response.html b/test/wpt/tests/fetch/range/non-matching-range-response.html new file mode 100644 index 0000000..ba76c36 --- /dev/null +++ b/test/wpt/tests/fetch/range/non-matching-range-response.html @@ -0,0 +1,34 @@ + + + + + + + diff --git a/test/wpt/tests/fetch/range/resources/basic.html b/test/wpt/tests/fetch/range/resources/basic.html new file mode 100644 index 0000000..0e76edd --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/basic.html @@ -0,0 +1 @@ + diff --git a/test/wpt/tests/fetch/range/resources/long-wav.py b/test/wpt/tests/fetch/range/resources/long-wav.py new file mode 100644 index 0000000..acfc81a --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/long-wav.py @@ -0,0 +1,134 @@ +""" +This generates a 30 minute silent wav, and is capable of +responding to Range requests. +""" +import time +import re +import struct + +from wptserve.utils import isomorphic_decode + +def create_wav_header(sample_rate, bit_depth, channels, duration): + bytes_per_sample = int(bit_depth / 8) + block_align = bytes_per_sample * channels + byte_rate = sample_rate * block_align + sub_chunk_2_size = duration * byte_rate + + data = b'' + # ChunkID + data += b'RIFF' + # ChunkSize + data += struct.pack(' 0: + to_send = b'\x00' * min(bytes_remaining_to_send, sample_rate) + bytes_remaining_to_send -= len(to_send) + + if not response.writer.write(to_send): + break + + # Throttle the stream + time.sleep(0.5) diff --git a/test/wpt/tests/fetch/range/resources/partial-script.py b/test/wpt/tests/fetch/range/resources/partial-script.py new file mode 100644 index 0000000..a9570ec --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/partial-script.py @@ -0,0 +1,29 @@ +""" +This generates a partial response containing valid JavaScript. +""" + +def main(request, response): + require_range = request.GET.first(b'require-range', b'') + pretend_offset = int(request.GET.first(b'pretend-offset', b'0')) + range_header = request.headers.get(b'Range', b'') + + if require_range and not range_header: + response.set_error(412, u"Range header required") + response.write() + return + + response.headers.set(b"Content-Type", b"text/plain") + response.headers.set(b"Accept-Ranges", b"bytes") + response.headers.set(b"Cache-Control", b"no-cache") + response.status = 206 + + to_send = b'self.scriptExecuted = true;' + length = len(to_send) + + content_range = b"bytes %d-%d/%d" % ( + pretend_offset, pretend_offset + length - 1, pretend_offset + length) + + response.headers.set(b"Content-Range", content_range) + response.headers.set(b"Content-Length", length) + + response.content = to_send diff --git a/test/wpt/tests/fetch/range/resources/partial-text.py b/test/wpt/tests/fetch/range/resources/partial-text.py new file mode 100644 index 0000000..fa3d117 --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/partial-text.py @@ -0,0 +1,53 @@ +""" +This generates a partial response for a 100-byte text file. +""" +import re + +from wptserve.utils import isomorphic_decode + +def main(request, response): + total_length = int(request.GET.first(b'length', b'100')) + partial_code = int(request.GET.first(b'partial', b'206')) + content_type = request.GET.first(b'type', b'text/plain') + range_header = request.headers.get(b'Range', b'') + + # Send a 200 if there is no range request + if not range_header: + to_send = ''.zfill(total_length) + response.headers.set(b"Content-Type", content_type) + response.headers.set(b"Cache-Control", b"no-cache") + response.headers.set(b"Content-Length", total_length) + response.content = to_send + return + + # Simple range parsing, requires specifically "bytes=xxx-xxxx" + range_header_match = re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header)) + start, end = range_header_match.groups() + start = int(start) + end = int(end) if end else total_length + length = end - start + + # Error the request if the range goes beyond the length + if length <= 0 or end > total_length: + response.set_error(416, u"Range Not Satisfiable") + # set_error sets the MIME type to application/json, which - for a + # no-cors media request - will be blocked by ORB. We'll just force + # the expected MIME type here, whichfixes the test, but doesn't make + # sense in general. + response.headers = [(b"Content-Type", content_type)] + response.write() + return + + # Generate a partial response of the requested length + to_send = ''.zfill(length) + response.headers.set(b"Content-Type", content_type) + response.headers.set(b"Accept-Ranges", b"bytes") + response.headers.set(b"Cache-Control", b"no-cache") + response.status = partial_code + + content_range = b"bytes %d-%d/%d" % (start, end, total_length) + + response.headers.set(b"Content-Range", content_range) + response.headers.set(b"Content-Length", length) + + response.content = to_send diff --git a/test/wpt/tests/fetch/range/resources/range-sw.js b/test/wpt/tests/fetch/range/resources/range-sw.js new file mode 100644 index 0000000..b47823f --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/range-sw.js @@ -0,0 +1,218 @@ +importScripts('/resources/testharness.js'); + +setup({ explicit_done: true }); + +function assert_range_request(request, expectedRangeHeader, name) { + assert_equals(request.headers.get('Range'), expectedRangeHeader, name); +} + +async function broadcast(msg) { + for (const client of await clients.matchAll()) { + client.postMessage(msg); + } +} + +addEventListener('fetch', async event => { + /** @type Request */ + const request = event.request; + const url = new URL(request.url); + const action = url.searchParams.get('action'); + + switch (action) { + case 'range-header-filter-test': + rangeHeaderFilterTest(request); + return; + case 'range-header-passthrough-test': + rangeHeaderPassthroughTest(event); + return; + case 'store-ranged-response': + storeRangedResponse(event); + return; + case 'use-stored-ranged-response': + useStoredRangeResponse(event); + return; + case 'broadcast-accept-encoding': + broadcastAcceptEncoding(event); + return; + case 'record-media-range-request': + return recordMediaRangeRequest(event); + case 'use-media-range-request': + useMediaRangeRequest(event); + return; + } +}); + +/** + * @param {Request} request + */ +function rangeHeaderFilterTest(request) { + const rangeValue = request.headers.get('Range'); + + test(() => { + assert_range_request(new Request(request), rangeValue, `Untampered`); + assert_range_request(new Request(request, {}), rangeValue, `Untampered (no init props set)`); + assert_range_request(new Request(request, { __foo: 'bar' }), rangeValue, `Untampered (only invalid props set)`); + assert_range_request(new Request(request, { mode: 'cors' }), rangeValue, `More permissive mode`); + assert_range_request(request.clone(), rangeValue, `Clone`); + }, "Range headers correctly preserved"); + + test(() => { + assert_range_request(new Request(request, { headers: { Range: 'foo' } }), null, `Tampered - range header set`); + assert_range_request(new Request(request, { headers: {} }), null, `Tampered - empty headers set`); + assert_range_request(new Request(request, { mode: 'no-cors' }), null, `Tampered – mode set`); + assert_range_request(new Request(request, { cache: 'no-cache' }), null, `Tampered – cache mode set`); + }, "Range headers correctly removed"); + + test(() => { + let headers; + + headers = new Request(request).headers; + headers.delete('does-not-exist'); + assert_equals(headers.get('Range'), rangeValue, `Preserved if no header actually removed`); + + headers = new Request(request).headers; + headers.append('foo', 'bar'); + assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on append (due to request-no-cors guard)`); + + headers = new Request(request).headers; + headers.set('foo', 'bar'); + assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on set (due to request-no-cors guard)`); + + headers = new Request(request).headers; + headers.append('Range', 'foo'); + assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on append (due to request-no-cors guard)`); + + headers = new Request(request).headers; + headers.set('Range', 'foo'); + assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on set (due to request-no-cors guard)`); + + headers = new Request(request).headers; + headers.append('Accept', 'whatever'); + assert_equals(headers.get('Range'), null, `Stripped if header successfully appended`); + + headers = new Request(request).headers; + headers.set('Accept', 'whatever'); + assert_equals(headers.get('Range'), null, `Stripped if header successfully set`); + + headers = new Request(request).headers; + headers.delete('Accept'); + assert_equals(headers.get('Range'), null, `Stripped if header successfully deleted`); + + headers = new Request(request).headers; + headers.delete('Range'); + assert_equals(headers.get('Range'), null, `Stripped if range header successfully deleted`); + }, "Headers correctly filtered"); + + done(); +} + +function rangeHeaderPassthroughTest(event) { + /** @type Request */ + const request = event.request; + const url = new URL(request.url); + const key = url.searchParams.get('range-received-key'); + + event.waitUntil(new Promise(resolve => { + promise_test(async () => { + await fetch(event.request); + const response = await fetch('stash-take.py?key=' + key); + assert_equals(await response.json(), 'range-header-received'); + resolve(); + }, `Include range header in network request`); + + done(); + })); + + // Just send back any response, it isn't important for the test. + event.respondWith(new Response('')); +} + +let storedRangeResponseP; + +function storeRangedResponse(event) { + /** @type Request */ + const request = event.request; + const id = new URL(request.url).searchParams.get('id'); + + storedRangeResponseP = fetch(event.request); + broadcast({ id }); + + // Just send back any response, it isn't important for the test. + event.respondWith(new Response('')); +} + +function useStoredRangeResponse(event) { + event.respondWith(async function() { + const response = await storedRangeResponseP; + if (!response) throw Error("Expected stored range response"); + return response.clone(); + }()); +} + +function broadcastAcceptEncoding(event) { + /** @type Request */ + const request = event.request; + const id = new URL(request.url).searchParams.get('id'); + + broadcast({ + id, + acceptEncoding: request.headers.get('Accept-Encoding') + }); + + // Just send back any response, it isn't important for the test. + event.respondWith(new Response('')); +} + +let rangeResponse = {}; + +async function recordMediaRangeRequest(event) { + /** @type Request */ + const request = event.request; + const url = new URL(request.url); + const urlParams = new URLSearchParams(url.search); + const size = urlParams.get("size"); + const id = urlParams.get('id'); + const key = 'size' + size; + + if (key in rangeResponse) { + // Don't re-fetch ranges we already have. + const clonedResponse = rangeResponse[key].clone(); + event.respondWith(clonedResponse); + } else if (event.request.headers.get("range") === "bytes=0-") { + // Generate a bogus 206 response to trigger subsequent range requests + // of the desired size. + const length = urlParams.get("length") + 100; + const body = "A".repeat(Number(size)); + event.respondWith(new Response(body, {status: 206, headers: { + "Content-Type": "audio/mp4", + "Content-Range": `bytes 0-1/${length}` + }})); + } else if (event.request.headers.get("range") === `bytes=${Number(size)}-`) { + // Pass through actual range requests which will attempt to fetch up to the + // length in the original response which is bigger than the actual resource + // to make sure 206 and 416 responses are treated the same. + rangeResponse[key] = await fetch(event.request); + + // Let the client know we have the range response for the given ID + broadcast({id}); + } else { + event.respondWith(Promise.reject(Error("Invalid Request"))); + } +} + +function useMediaRangeRequest(event) { + /** @type Request */ + const request = event.request; + const url = new URL(request.url); + const urlParams = new URLSearchParams(url.search); + const size = urlParams.get("size"); + const key = 'size' + size; + + // Send a clone of the range response to preload. + if (key in rangeResponse) { + const clonedResponse = rangeResponse[key].clone(); + event.respondWith(clonedResponse); + } else { + event.respondWith(Promise.reject(Error("Invalid Request"))); + } +} diff --git a/test/wpt/tests/fetch/range/resources/stash-take.py b/test/wpt/tests/fetch/range/resources/stash-take.py new file mode 100644 index 0000000..6cf6ff5 --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/stash-take.py @@ -0,0 +1,7 @@ +from wptserve.handlers import json_handler + + +@json_handler +def main(request, response): + key = request.GET.first(b"key") + return request.server.stash.take(key, b'/fetch/range/') diff --git a/test/wpt/tests/fetch/range/resources/utils.js b/test/wpt/tests/fetch/range/resources/utils.js new file mode 100644 index 0000000..ad2853b --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/utils.js @@ -0,0 +1,36 @@ +function loadScript(url, { doc = document }={}) { + return new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.onload = () => resolve(); + script.onerror = () => reject(Error("Script load failed")); + script.src = url; + doc.body.appendChild(script); + }) +} + +function preloadImage(url, { doc = document }={}) { + return new Promise((resolve, reject) => { + const preload = doc.createElement('link'); + preload.rel = 'preload'; + preload.as = 'image'; + preload.onload = () => resolve(); + preload.onerror = () => resolve(); + preload.href = url; + doc.body.appendChild(preload); + }) +} + +/** + * + * @param {Document} document + * @param {string|URL} url + * @returns {HTMLAudioElement} + */ +function appendAudio(document, url) { + const audio = document.createElement('audio'); + audio.muted = true; + audio.src = url; + audio.preload = true; + document.body.appendChild(audio); + return audio; +} diff --git a/test/wpt/tests/fetch/range/resources/video-with-range.py b/test/wpt/tests/fetch/range/resources/video-with-range.py new file mode 100644 index 0000000..2d15ccf --- /dev/null +++ b/test/wpt/tests/fetch/range/resources/video-with-range.py @@ -0,0 +1,43 @@ +import re +import os +import json +from wptserve.utils import isomorphic_decode + +def main(request, response): + path = os.path.join(request.doc_root, u"media", "sine440.mp3") + total_size = os.path.getsize(path) + rewrites = json.loads(request.GET.first(b'rewrites', '[]')) + range_header = request.headers.get(b'Range') + range_header_match = range_header and re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header)) + start = None + end = None + if range_header_match: + response.status = 206 + start, end = range_header_match.groups() + if range_header: + status = 206 + else: + status = 200 + for rewrite in rewrites: + req_start, req_end = rewrite['request'] + if start == req_start or req_start == '*': + if end == req_end or req_end == '*': + if 'response' in rewrite: + start, end = rewrite['response'] + if 'status' in rewrite: + status = rewrite['status'] + + start = int(start or 0) + end = int(end or total_size) + headers = [] + if status == 206: + headers.append((b"Content-Range", b"bytes %d-%d/%d" % (start, end - 1, total_size))) + headers.append((b"Accept-Ranges", b"bytes")) + + headers.append((b"Content-Type", b"audio/mp3")) + headers.append((b"Content-Length", str(end - start))) + headers.append((b"Cache-Control", b"no-cache")) + video_file = open(path, "rb") + video_file.seek(start) + content = video_file.read(end) + return status, headers, content diff --git a/test/wpt/tests/fetch/range/sw.https.window.js b/test/wpt/tests/fetch/range/sw.https.window.js new file mode 100644 index 0000000..62ad894 --- /dev/null +++ b/test/wpt/tests/fetch/range/sw.https.window.js @@ -0,0 +1,228 @@ +// META: script=../../../service-workers/service-worker/resources/test-helpers.sub.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/utils.js + +const { REMOTE_HOST } = get_host_info(); +const BASE_SCOPE = 'resources/basic.html?'; + +async function cleanup() { + for (const iframe of document.querySelectorAll('.test-iframe')) { + iframe.parentNode.removeChild(iframe); + } + + for (const reg of await navigator.serviceWorker.getRegistrations()) { + await reg.unregister(); + } +} + +async function setupRegistration(t, scope) { + await cleanup(); + const reg = await navigator.serviceWorker.register('resources/range-sw.js', { scope }); + await wait_for_state(t, reg.installing, 'activated'); + return reg; +} + +function awaitMessage(obj, id) { + return new Promise(resolve => { + obj.addEventListener('message', function listener(event) { + if (event.data.id !== id) return; + obj.removeEventListener('message', listener); + resolve(event.data); + }); + }); +} + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + const reg = await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + + // Trigger a cross-origin range request using media + const url = new URL('long-wav.py?action=range-header-filter-test', w.location); + url.hostname = REMOTE_HOST; + appendAudio(w.document, url); + + // See rangeHeaderFilterTest in resources/range-sw.js + await fetch_tests_from_worker(reg.active); +}, `Defer range header filter tests to service worker`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + const reg = await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + + // Trigger a cross-origin range request using media + const url = new URL('long-wav.py', w.location); + url.searchParams.set('action', 'range-header-passthrough-test'); + url.searchParams.set('range-received-key', token()); + url.hostname = REMOTE_HOST; + appendAudio(w.document, url); + + // See rangeHeaderPassthroughTest in resources/range-sw.js + await fetch_tests_from_worker(reg.active); +}, `Defer range header passthrough tests to service worker`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + // Trigger a cross-origin range request using media + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('action', 'store-ranged-response'); + url.searchParams.set('id', id); + url.hostname = REMOTE_HOST; + + appendAudio(w.document, url); + + await storedRangeResponse; + + // Fetching should reject + const fetchPromise = w.fetch('?action=use-stored-ranged-response', { mode: 'no-cors' }); + await promise_rejects_js(t, w.TypeError, fetchPromise); + + // Script loading should error too + const loadScriptPromise = loadScript('?action=use-stored-ranged-response', { doc: w.document }); + await promise_rejects_js(t, Error, loadScriptPromise); + + await loadScriptPromise.catch(() => {}); + + assert_false(!!w.scriptExecuted, `Partial response shouldn't be executed`); +}, `Ranged response not allowed following no-cors ranged request`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + // Trigger a range request using media + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('action', 'store-ranged-response'); + url.searchParams.set('id', id); + + appendAudio(w.document, url); + + await storedRangeResponse; + + // This should not throw + await w.fetch('?action=use-stored-ranged-response'); + + // This shouldn't throw either + await loadScript('?action=use-stored-ranged-response', { doc: w.document }); + + assert_true(w.scriptExecuted, `Partial response should be executed`); +}, `Non-opaque ranged response executed`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const fetchId = Math.random() + ''; + const fetchBroadcast = awaitMessage(w.navigator.serviceWorker, fetchId); + const audioId = Math.random() + ''; + const audioBroadcast = awaitMessage(w.navigator.serviceWorker, audioId); + + const url = new URL('long-wav.py', w.location); + url.searchParams.set('action', 'broadcast-accept-encoding'); + url.searchParams.set('id', fetchId); + + await w.fetch(url, { + headers: { Range: 'bytes=0-10' } + }); + + assert_equals((await fetchBroadcast).acceptEncoding, null, "Accept-Encoding should not be set for fetch"); + + url.searchParams.set('id', audioId); + appendAudio(w.document, url); + + assert_equals((await audioBroadcast).acceptEncoding, null, "Accept-Encoding should not be set for media"); +}, `Accept-Encoding should not appear in a service worker`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const length = 100; + const count = 3; + const counts = {}; + + // test a single range request size + async function testSizedRange(size, partialResponseCode) { + const rangeId = Math.random() + ''; + const rangeBroadcast = awaitMessage(w.navigator.serviceWorker, rangeId); + + // Create a bogus audio element to trick the browser into sending + // cross-origin range requests that can be manipulated by the service worker. + const sound_url = new URL('partial-text.py', w.location); + sound_url.hostname = REMOTE_HOST; + sound_url.searchParams.set('action', 'record-media-range-request'); + sound_url.searchParams.set('length', length); + sound_url.searchParams.set('size', size); + sound_url.searchParams.set('partial', partialResponseCode); + sound_url.searchParams.set('id', rangeId); + sound_url.searchParams.set('type', 'audio/mp4'); + appendAudio(w.document, sound_url); + + // wait for the range requests to happen + await rangeBroadcast; + + // Create multiple preload requests and count the number of resource timing + // entries that get created to make sure 206 and 416 range responses are treated + // the same. + const url = new URL('partial-text.py', w.location); + url.searchParams.set('action', 'use-media-range-request'); + url.searchParams.set('size', size); + url.searchParams.set('type', 'audio/mp4'); + counts['size' + size] = 0; + for (let i = 0; i < count; i++) { + await preloadImage(url, { doc: w.document }); + } + } + + // Test range requests from 1 smaller than the correct size to 1 larger than + // the correct size to exercise the various permutations using the default 206 + // response code for successful range requests. + for (let size = length - 1; size <= length + 1; size++) { + await testSizedRange(size, '206'); + } + + // Test a successful range request using a 200 response. + await testSizedRange(length - 2, '200'); + + // Check the resource timing entries and count the reported number of fetches of each type + const resources = w.performance.getEntriesByType("resource"); + for (const entry of resources) { + const url = new URL(entry.name); + if (url.searchParams.has('action') && + url.searchParams.get('action') == 'use-media-range-request' && + url.searchParams.has('size')) { + counts['size' + url.searchParams.get('size')]++; + } + } + + // Make sure there are a non-zero number of preload requests and they are all the same + let counts_valid = true; + const first = 'size' + (length - 2); + for (let size = length - 2; size <= length + 1; size++) { + let key = 'size' + size; + if (!(key in counts) || counts[key] <= 0 || counts[key] != counts[first]) { + counts_valid = false; + break; + } + } + + assert_true(counts_valid, `Opaque range request preloads were different for error and success`); +}, `Opaque range preload successes and failures should be indistinguishable`); diff --git a/test/wpt/tests/fetch/redirect-navigate/302-found-post-handler.py b/test/wpt/tests/fetch/redirect-navigate/302-found-post-handler.py new file mode 100644 index 0000000..40a224f --- /dev/null +++ b/test/wpt/tests/fetch/redirect-navigate/302-found-post-handler.py @@ -0,0 +1,15 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + if request.method == u"POST": + response.add_required_headers = False + response.writer.write_status(302) + response.writer.write_header(b"Location", isomorphic_encode(request.url)) + response.writer.end_headers() + response.writer.write(b"") + elif request.method == u"GET": + return ([(b"Content-Type", b"text/plain")], + b"OK") + else: + return ([(b"Content-Type", b"text/plain")], + b"FAIL") \ No newline at end of file diff --git a/test/wpt/tests/fetch/redirect-navigate/302-found-post.html b/test/wpt/tests/fetch/redirect-navigate/302-found-post.html new file mode 100644 index 0000000..854cd32 --- /dev/null +++ b/test/wpt/tests/fetch/redirect-navigate/302-found-post.html @@ -0,0 +1,20 @@ + + +HTTP 302 Found POST Navigation Test + + + + + diff --git a/test/wpt/tests/fetch/redirect-navigate/preserve-fragment.html b/test/wpt/tests/fetch/redirect-navigate/preserve-fragment.html new file mode 100644 index 0000000..682539a --- /dev/null +++ b/test/wpt/tests/fetch/redirect-navigate/preserve-fragment.html @@ -0,0 +1,202 @@ + + + + + Ensure fragment is kept across redirects + + + + + + + + + + + + + + diff --git a/test/wpt/tests/fetch/redirect-navigate/resources/destination.html b/test/wpt/tests/fetch/redirect-navigate/resources/destination.html new file mode 100644 index 0000000..f98c5a8 --- /dev/null +++ b/test/wpt/tests/fetch/redirect-navigate/resources/destination.html @@ -0,0 +1,28 @@ + + + + + + + + +

Target

+

Target

+ + diff --git a/test/wpt/tests/fetch/redirects/data.window.js b/test/wpt/tests/fetch/redirects/data.window.js new file mode 100644 index 0000000..eeb4196 --- /dev/null +++ b/test/wpt/tests/fetch/redirects/data.window.js @@ -0,0 +1,25 @@ +// See ../api/redirect/redirect-to-dataurl.any.js for fetch() tests + +async_test(t => { + const img = document.createElement("img"); + img.onload = t.unreached_func(); + img.onerror = t.step_func_done(); + img.src = "../api/resources/redirect.py?location=data:image/png%3Bbase64,iVBORw0KGgoAAAANSUhEUgAAAIUAAABqCAIAAAAdqgU8AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAF6SURBVHhe7dNBDQAADIPA%2Bje92eBxSQUQSLedlQzo0TLQonFWPVoGWjT%2BoUfLQIvGP/RoGWjR%2BIceLQMtGv/Qo2WgReMferQMtGj8Q4%2BWgRaNf%2BjRMtCi8Q89WgZaNP6hR8tAi8Y/9GgZaNH4hx4tAy0a/9CjZaBF4x96tAy0aPxDj5aBFo1/6NEy0KLxDz1aBlo0/qFHy0CLxj/0aBlo0fiHHi0DLRr/0KNloEXjH3q0DLRo/EOPloEWjX/o0TLQovEPPVoGWjT%2BoUfLQIvGP/RoGWjR%2BIceLQMtGv/Qo2WgReMferQMtGj8Q4%2BWgRaNf%2BjRMtCi8Q89WgZaNP6hR8tAi8Y/9GgZaNH4hx4tAy0a/9CjZaBF4x96tAy0aPxDj5aBFo1/6NEy0KLxDz1aBlo0/qFHy0CLxj/0aBlo0fiHHi0DLRr/0KNloEXjH3q0DLRo/EOPloEWjX/o0TLQovEPPVoGWjT%2BoUfLQIvGP/RoGWjR%2BIceJQMPIOzeGc0PIDEAAAAASUVORK5CYII"; +}, " fetch that redirects to data: URL"); + +globalThis.globalTest = null; +async_test(t => { + globalThis.globalTest = t; + const script = document.createElement("script"); + script.src = "../api/resources/redirect.py?location=data:text/javascript,(globalThis.globalTest.unreached_func())()"; + script.onerror = t.step_func_done(); + document.body.append(script); +}, " + + +
+ + + + + + + diff --git a/test/wpt/tests/fetch/security/1xx-response.any.js b/test/wpt/tests/fetch/security/1xx-response.any.js new file mode 100644 index 0000000..df4dafc --- /dev/null +++ b/test/wpt/tests/fetch/security/1xx-response.any.js @@ -0,0 +1,28 @@ +promise_test(async (t) => { + // The 100 response should be ignored, then the transaction ends, which + // should lead to an error. + await promise_rejects_js( + t, TypeError, fetch('/common/text-plain.txt?pipe=status(100)')); +}, 'Status(100) should be ignored.'); + +// This behavior is being discussed at https://github.com/whatwg/fetch/issues/1397. +promise_test(async (t) => { + const res = await fetch('/common/text-plain.txt?pipe=status(101)'); + assert_equals(res.status, 101); + const body = await res.text(); + assert_equals(body, ''); +}, 'Status(101) should be accepted, with removing body.'); + +promise_test(async (t) => { + // The 103 response should be ignored, then the transaction ends, which + // should lead to an error. + await promise_rejects_js( + t, TypeError, fetch('/common/text-plain.txt?pipe=status(103)')); +}, 'Status(103) should be ignored.'); + +promise_test(async (t) => { + // The 199 response should be ignored, then the transaction ends, which + // should lead to an error. + await promise_rejects_js( + t, TypeError, fetch('/common/text-plain.txt?pipe=status(199)')); +}, 'Status(199) should be ignored.'); diff --git a/test/wpt/tests/fetch/security/dangling-markup-mitigation-data-url.tentative.sub.html b/test/wpt/tests/fetch/security/dangling-markup-mitigation-data-url.tentative.sub.html new file mode 100644 index 0000000..f27735d --- /dev/null +++ b/test/wpt/tests/fetch/security/dangling-markup-mitigation-data-url.tentative.sub.html @@ -0,0 +1,229 @@ + + + + + diff --git a/test/wpt/tests/fetch/security/dangling-markup-mitigation.tentative.html b/test/wpt/tests/fetch/security/dangling-markup-mitigation.tentative.html new file mode 100644 index 0000000..61a9316 --- /dev/null +++ b/test/wpt/tests/fetch/security/dangling-markup-mitigation.tentative.html @@ -0,0 +1,147 @@ + + + + + diff --git a/test/wpt/tests/fetch/security/embedded-credentials.tentative.sub.html b/test/wpt/tests/fetch/security/embedded-credentials.tentative.sub.html new file mode 100644 index 0000000..ca5ee1c --- /dev/null +++ b/test/wpt/tests/fetch/security/embedded-credentials.tentative.sub.html @@ -0,0 +1,89 @@ + + + + + diff --git a/test/wpt/tests/fetch/security/redirect-to-url-with-credentials.https.html b/test/wpt/tests/fetch/security/redirect-to-url-with-credentials.https.html new file mode 100644 index 0000000..b064648 --- /dev/null +++ b/test/wpt/tests/fetch/security/redirect-to-url-with-credentials.https.html @@ -0,0 +1,68 @@ + +
+ + + +
+ + + + diff --git a/test/wpt/tests/fetch/security/support/embedded-credential-window.sub.html b/test/wpt/tests/fetch/security/support/embedded-credential-window.sub.html new file mode 100644 index 0000000..20d307e --- /dev/null +++ b/test/wpt/tests/fetch/security/support/embedded-credential-window.sub.html @@ -0,0 +1,19 @@ + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/fetch-sw.https.html b/test/wpt/tests/fetch/stale-while-revalidate/fetch-sw.https.html new file mode 100644 index 0000000..efcebc2 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/fetch-sw.https.html @@ -0,0 +1,65 @@ + + + + + Stale Revalidation Requests don't get sent to service worker + + + + + + + + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/fetch.any.js b/test/wpt/tests/fetch/stale-while-revalidate/fetch.any.js new file mode 100644 index 0000000..3682b9d --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/fetch.any.js @@ -0,0 +1,32 @@ +// META: global=window,worker +// META: title=Tests Stale While Revalidate is executed for fetch API +// META: script=/common/utils.js + +function wait25ms(test) { + return new Promise(resolve => { + test.step_timeout(() => { + resolve(); + }, 25); + }); +} + +promise_test(async (test) => { + var request_token = token(); + + const response = await fetch(`resources/stale-script.py?token=` + request_token); + // Wait until resource is completely fetched to allow caching before next fetch. + const body = await response.text(); + const response2 = await fetch(`resources/stale-script.py?token=` + request_token); + + assert_equals(response.headers.get('Unique-Id'), response2.headers.get('Unique-Id')); + const body2 = await response2.text(); + assert_equals(body, body2); + + while(true) { + const revalidation_check = await fetch(`resources/stale-script.py?query&token=` + request_token); + if (revalidation_check.headers.get('Count') == '2') { + break; + } + await wait25ms(test); + } +}, 'Second fetch returns same response'); diff --git a/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-css.py b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-css.py new file mode 100644 index 0000000..b876683 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-css.py @@ -0,0 +1,28 @@ +def main(request, response): + + token = request.GET.first(b"token", None) + is_query = request.GET.first(b"query", None) != None + with request.server.stash.lock: + value = request.server.stash.take(token) + count = 0 + if value != None: + count = int(value) + if is_query: + if count < 2: + request.server.stash.put(token, count) + else: + count = count + 1 + request.server.stash.put(token, count) + if is_query: + headers = [(b"Count", count)] + content = b"" + return 200, headers, content + else: + content = b"body { background: rgb(0, 128, 0); }" + if count > 1: + content = b"body { background: rgb(255, 0, 0); }" + + headers = [(b"Content-Type", b"text/css"), + (b"Cache-Control", b"private, max-age=0, stale-while-revalidate=60")] + + return 200, headers, content diff --git a/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-image.py b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-image.py new file mode 100644 index 0000000..36e6fc0 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-image.py @@ -0,0 +1,40 @@ +import os.path + +from wptserve.utils import isomorphic_decode + +def main(request, response): + + token = request.GET.first(b"token", None) + is_query = request.GET.first(b"query", None) != None + with request.server.stash.lock: + value = request.server.stash.take(token) + count = 0 + if value != None: + count = int(value) + if is_query: + if count < 2: + request.server.stash.put(token, count) + else: + count = count + 1 + request.server.stash.put(token, count) + + if is_query: + headers = [(b"Count", count)] + content = b"" + return 200, headers, content + else: + filename = u"green-16x16.png" + if count > 1: + filename = u"green-256x256.png" + + path = os.path.join(os.path.dirname(isomorphic_decode(__file__)), u"../../../images", filename) + body = open(path, "rb").read() + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-length", len(body)) + response.writer.write_header(b"Cache-Control", b"private, max-age=0, stale-while-revalidate=60") + response.writer.write_header(b"content-type", b"image/png") + response.writer.end_headers() + + response.writer.write(body) diff --git a/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-script.py b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-script.py new file mode 100644 index 0000000..731cd80 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/resources/stale-script.py @@ -0,0 +1,32 @@ +import random, string + +def id_token(): + letters = string.ascii_lowercase + return b''.join(random.choice(letters).encode("utf-8") for i in range(20)) + +def main(request, response): + token = request.GET.first(b"token", None) + is_query = request.GET.first(b"query", None) != None + with request.server.stash.lock: + value = request.server.stash.take(token) + count = 0 + if value != None: + count = int(value) + if is_query: + if count < 2: + request.server.stash.put(token, count) + else: + count = count + 1 + request.server.stash.put(token, count) + + if is_query: + headers = [(b"Count", count)] + content = u"" + return 200, headers, content + else: + unique_id = id_token() + headers = [(b"Content-Type", b"text/javascript"), + (b"Cache-Control", b"private, max-age=0, stale-while-revalidate=60"), + (b"Unique-Id", unique_id)] + content = b"report('%s')" % unique_id + return 200, headers, content diff --git a/test/wpt/tests/fetch/stale-while-revalidate/revalidate-not-blocked-by-csp.html b/test/wpt/tests/fetch/stale-while-revalidate/revalidate-not-blocked-by-csp.html new file mode 100644 index 0000000..ea70b9a --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/revalidate-not-blocked-by-csp.html @@ -0,0 +1,69 @@ + + +Test revalidations requests aren't blocked by CSP. + + + + + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/stale-css.html b/test/wpt/tests/fetch/stale-while-revalidate/stale-css.html new file mode 100644 index 0000000..603a60c --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/stale-css.html @@ -0,0 +1,51 @@ + + +Tests Stale While Revalidate works for css + + + + + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/stale-image.html b/test/wpt/tests/fetch/stale-while-revalidate/stale-image.html new file mode 100644 index 0000000..d86bdfb --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/stale-image.html @@ -0,0 +1,55 @@ + + +Tests Stale While Revalidate works for images + + + + + + + + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/stale-script.html b/test/wpt/tests/fetch/stale-while-revalidate/stale-script.html new file mode 100644 index 0000000..f531748 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/stale-script.html @@ -0,0 +1,59 @@ + + +Tests Stale While Revalidate works for scripts + + + + + + diff --git a/test/wpt/tests/fetch/stale-while-revalidate/sw-intercept.js b/test/wpt/tests/fetch/stale-while-revalidate/sw-intercept.js new file mode 100644 index 0000000..dca7de5 --- /dev/null +++ b/test/wpt/tests/fetch/stale-while-revalidate/sw-intercept.js @@ -0,0 +1,14 @@ +async function broadcast(msg) { + for (const client of await clients.matchAll()) { + client.postMessage(msg); + } +} + +self.addEventListener('fetch', event => { + event.waitUntil(broadcast(event.request.url)); + event.respondWith(fetch(event.request)); +}); + +self.addEventListener('activate', event => { + self.clients.claim(); +}); diff --git a/test/wpt/tests/interfaces/ANGLE_instanced_arrays.idl b/test/wpt/tests/interfaces/ANGLE_instanced_arrays.idl new file mode 100644 index 0000000..557a416 --- /dev/null +++ b/test/wpt/tests/interfaces/ANGLE_instanced_arrays.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL ANGLE_instanced_arrays Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/ANGLE_instanced_arrays/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface ANGLE_instanced_arrays { + const GLenum VERTEX_ATTRIB_ARRAY_DIVISOR_ANGLE = 0x88FE; + undefined drawArraysInstancedANGLE(GLenum mode, GLint first, GLsizei count, GLsizei primcount); + undefined drawElementsInstancedANGLE(GLenum mode, GLsizei count, GLenum type, GLintptr offset, GLsizei primcount); + undefined vertexAttribDivisorANGLE(GLuint index, GLuint divisor); +}; diff --git a/test/wpt/tests/interfaces/CSP.idl b/test/wpt/tests/interfaces/CSP.idl new file mode 100644 index 0000000..ac0a6ff --- /dev/null +++ b/test/wpt/tests/interfaces/CSP.idl @@ -0,0 +1,56 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Content Security Policy Level 3 (https://w3c.github.io/webappsec-csp/) + +[Exposed=Window] +interface CSPViolationReportBody : ReportBody { + [Default] object toJSON(); + readonly attribute USVString documentURL; + readonly attribute USVString? referrer; + readonly attribute USVString? blockedURL; + readonly attribute DOMString effectiveDirective; + readonly attribute DOMString originalPolicy; + readonly attribute USVString? sourceFile; + readonly attribute DOMString? sample; + readonly attribute SecurityPolicyViolationEventDisposition disposition; + readonly attribute unsigned short statusCode; + readonly attribute unsigned long? lineNumber; + readonly attribute unsigned long? columnNumber; +}; + +enum SecurityPolicyViolationEventDisposition { + "enforce", "report" +}; + +[Exposed=(Window,Worker)] +interface SecurityPolicyViolationEvent : Event { + constructor(DOMString type, optional SecurityPolicyViolationEventInit eventInitDict = {}); + readonly attribute USVString documentURI; + readonly attribute USVString referrer; + readonly attribute USVString blockedURI; + readonly attribute DOMString effectiveDirective; + readonly attribute DOMString violatedDirective; // historical alias of effectiveDirective + readonly attribute DOMString originalPolicy; + readonly attribute USVString sourceFile; + readonly attribute DOMString sample; + readonly attribute SecurityPolicyViolationEventDisposition disposition; + readonly attribute unsigned short statusCode; + readonly attribute unsigned long lineNumber; + readonly attribute unsigned long columnNumber; +}; + +dictionary SecurityPolicyViolationEventInit : EventInit { + required USVString documentURI; + USVString referrer = ""; + USVString blockedURI = ""; + required DOMString violatedDirective; + required DOMString effectiveDirective; + required DOMString originalPolicy; + USVString sourceFile = ""; + DOMString sample = ""; + required SecurityPolicyViolationEventDisposition disposition; + required unsigned short statusCode; + unsigned long lineNumber = 0; + unsigned long columnNumber = 0; +}; diff --git a/test/wpt/tests/interfaces/DOM-Parsing.idl b/test/wpt/tests/interfaces/DOM-Parsing.idl new file mode 100644 index 0000000..d0d84ab --- /dev/null +++ b/test/wpt/tests/interfaces/DOM-Parsing.idl @@ -0,0 +1,26 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: DOM Parsing and Serialization (https://w3c.github.io/DOM-Parsing/) + +[Exposed=Window] +interface XMLSerializer { + constructor(); + DOMString serializeToString(Node root); +}; + +interface mixin InnerHTML { + [CEReactions] attribute [LegacyNullToEmptyString] DOMString innerHTML; +}; + +Element includes InnerHTML; +ShadowRoot includes InnerHTML; + +partial interface Element { + [CEReactions] attribute [LegacyNullToEmptyString] DOMString outerHTML; + [CEReactions] undefined insertAdjacentHTML(DOMString position, DOMString text); +}; + +partial interface Range { + [CEReactions, NewObject] DocumentFragment createContextualFragment(DOMString fragment); +}; diff --git a/test/wpt/tests/interfaces/EXT_blend_minmax.idl b/test/wpt/tests/interfaces/EXT_blend_minmax.idl new file mode 100644 index 0000000..fd7d26e --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_blend_minmax.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_blend_minmax Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_blend_minmax/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_blend_minmax { + const GLenum MIN_EXT = 0x8007; + const GLenum MAX_EXT = 0x8008; +}; diff --git a/test/wpt/tests/interfaces/EXT_color_buffer_float.idl b/test/wpt/tests/interfaces/EXT_color_buffer_float.idl new file mode 100644 index 0000000..09bd397 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_color_buffer_float.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_color_buffer_float Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_color_buffer_float/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_color_buffer_float { +}; // interface EXT_color_buffer_float diff --git a/test/wpt/tests/interfaces/EXT_color_buffer_half_float.idl b/test/wpt/tests/interfaces/EXT_color_buffer_half_float.idl new file mode 100644 index 0000000..7197e44 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_color_buffer_half_float.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_color_buffer_half_float Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_color_buffer_half_float/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_color_buffer_half_float { + const GLenum RGBA16F_EXT = 0x881A; + const GLenum RGB16F_EXT = 0x881B; + const GLenum FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE_EXT = 0x8211; + const GLenum UNSIGNED_NORMALIZED_EXT = 0x8C17; +}; // interface EXT_color_buffer_half_float diff --git a/test/wpt/tests/interfaces/EXT_disjoint_timer_query.idl b/test/wpt/tests/interfaces/EXT_disjoint_timer_query.idl new file mode 100644 index 0000000..cf0c8d9 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_disjoint_timer_query.idl @@ -0,0 +1,30 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_disjoint_timer_query Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_disjoint_timer_query/) + +typedef unsigned long long GLuint64EXT; + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WebGLTimerQueryEXT : WebGLObject { +}; + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_disjoint_timer_query { + const GLenum QUERY_COUNTER_BITS_EXT = 0x8864; + const GLenum CURRENT_QUERY_EXT = 0x8865; + const GLenum QUERY_RESULT_EXT = 0x8866; + const GLenum QUERY_RESULT_AVAILABLE_EXT = 0x8867; + const GLenum TIME_ELAPSED_EXT = 0x88BF; + const GLenum TIMESTAMP_EXT = 0x8E28; + const GLenum GPU_DISJOINT_EXT = 0x8FBB; + + WebGLTimerQueryEXT? createQueryEXT(); + undefined deleteQueryEXT(WebGLTimerQueryEXT? query); + [WebGLHandlesContextLoss] boolean isQueryEXT(WebGLTimerQueryEXT? query); + undefined beginQueryEXT(GLenum target, WebGLTimerQueryEXT query); + undefined endQueryEXT(GLenum target); + undefined queryCounterEXT(WebGLTimerQueryEXT query, GLenum target); + any getQueryEXT(GLenum target, GLenum pname); + any getQueryObjectEXT(WebGLTimerQueryEXT query, GLenum pname); +}; diff --git a/test/wpt/tests/interfaces/EXT_disjoint_timer_query_webgl2.idl b/test/wpt/tests/interfaces/EXT_disjoint_timer_query_webgl2.idl new file mode 100644 index 0000000..689203c --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_disjoint_timer_query_webgl2.idl @@ -0,0 +1,14 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_disjoint_timer_query_webgl2 Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_disjoint_timer_query_webgl2/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_disjoint_timer_query_webgl2 { + const GLenum QUERY_COUNTER_BITS_EXT = 0x8864; + const GLenum TIME_ELAPSED_EXT = 0x88BF; + const GLenum TIMESTAMP_EXT = 0x8E28; + const GLenum GPU_DISJOINT_EXT = 0x8FBB; + + undefined queryCounterEXT(WebGLQuery query, GLenum target); +}; diff --git a/test/wpt/tests/interfaces/EXT_float_blend.idl b/test/wpt/tests/interfaces/EXT_float_blend.idl new file mode 100644 index 0000000..58ec47e --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_float_blend.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_float_blend Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_float_blend/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_float_blend { +}; // interface EXT_float_blend diff --git a/test/wpt/tests/interfaces/EXT_frag_depth.idl b/test/wpt/tests/interfaces/EXT_frag_depth.idl new file mode 100644 index 0000000..1ae6896 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_frag_depth.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_frag_depth Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_frag_depth/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_frag_depth { +}; diff --git a/test/wpt/tests/interfaces/EXT_sRGB.idl b/test/wpt/tests/interfaces/EXT_sRGB.idl new file mode 100644 index 0000000..3c03c33 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_sRGB.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_sRGB Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_sRGB/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_sRGB { + const GLenum SRGB_EXT = 0x8C40; + const GLenum SRGB_ALPHA_EXT = 0x8C42; + const GLenum SRGB8_ALPHA8_EXT = 0x8C43; + const GLenum FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING_EXT = 0x8210; +}; diff --git a/test/wpt/tests/interfaces/EXT_shader_texture_lod.idl b/test/wpt/tests/interfaces/EXT_shader_texture_lod.idl new file mode 100644 index 0000000..13df26c --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_shader_texture_lod.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_shader_texture_lod Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_shader_texture_lod/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_shader_texture_lod { +}; diff --git a/test/wpt/tests/interfaces/EXT_texture_compression_bptc.idl b/test/wpt/tests/interfaces/EXT_texture_compression_bptc.idl new file mode 100644 index 0000000..2772980 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_texture_compression_bptc.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_texture_compression_bptc Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_texture_compression_bptc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_texture_compression_bptc { + const GLenum COMPRESSED_RGBA_BPTC_UNORM_EXT = 0x8E8C; + const GLenum COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT = 0x8E8D; + const GLenum COMPRESSED_RGB_BPTC_SIGNED_FLOAT_EXT = 0x8E8E; + const GLenum COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT_EXT = 0x8E8F; +}; diff --git a/test/wpt/tests/interfaces/EXT_texture_compression_rgtc.idl b/test/wpt/tests/interfaces/EXT_texture_compression_rgtc.idl new file mode 100644 index 0000000..f12b962 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_texture_compression_rgtc.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_texture_compression_rgtc Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_texture_compression_rgtc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_texture_compression_rgtc { + const GLenum COMPRESSED_RED_RGTC1_EXT = 0x8DBB; + const GLenum COMPRESSED_SIGNED_RED_RGTC1_EXT = 0x8DBC; + const GLenum COMPRESSED_RED_GREEN_RGTC2_EXT = 0x8DBD; + const GLenum COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT = 0x8DBE; +}; diff --git a/test/wpt/tests/interfaces/EXT_texture_filter_anisotropic.idl b/test/wpt/tests/interfaces/EXT_texture_filter_anisotropic.idl new file mode 100644 index 0000000..5c78bfa --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_texture_filter_anisotropic.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_texture_filter_anisotropic Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_texture_filter_anisotropic/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_texture_filter_anisotropic { + const GLenum TEXTURE_MAX_ANISOTROPY_EXT = 0x84FE; + const GLenum MAX_TEXTURE_MAX_ANISOTROPY_EXT = 0x84FF; +}; diff --git a/test/wpt/tests/interfaces/EXT_texture_norm16.idl b/test/wpt/tests/interfaces/EXT_texture_norm16.idl new file mode 100644 index 0000000..1fe5ed8 --- /dev/null +++ b/test/wpt/tests/interfaces/EXT_texture_norm16.idl @@ -0,0 +1,16 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL EXT_texture_norm16 Extension Specification (https://registry.khronos.org/webgl/extensions/EXT_texture_norm16/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface EXT_texture_norm16 { + const GLenum R16_EXT = 0x822A; + const GLenum RG16_EXT = 0x822C; + const GLenum RGB16_EXT = 0x8054; + const GLenum RGBA16_EXT = 0x805B; + const GLenum R16_SNORM_EXT = 0x8F98; + const GLenum RG16_SNORM_EXT = 0x8F99; + const GLenum RGB16_SNORM_EXT = 0x8F9A; + const GLenum RGBA16_SNORM_EXT = 0x8F9B; +}; diff --git a/test/wpt/tests/interfaces/FedCM.idl b/test/wpt/tests/interfaces/FedCM.idl new file mode 100644 index 0000000..8de87e8 --- /dev/null +++ b/test/wpt/tests/interfaces/FedCM.idl @@ -0,0 +1,67 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Federated Credential Management API (https://fedidcg.github.io/FedCM/) + +[Exposed=Window, SecureContext] +interface IdentityCredential : Credential { + readonly attribute USVString? token; +}; + +partial dictionary CredentialRequestOptions { + IdentityCredentialRequestOptions identity; +}; + +dictionary IdentityCredentialRequestOptions { + sequence providers; +}; + +dictionary IdentityProviderConfig { + required USVString configURL; + required USVString clientId; + USVString nonce; +}; + +dictionary IdentityProviderWellKnown { + required sequence provider_urls; +}; + +dictionary IdentityProviderIcon { + required USVString url; + unsigned long size; +}; + +dictionary IdentityProviderBranding { + USVString background_color; + USVString color; + sequence icons; + USVString name; +}; + +dictionary IdentityProviderAPIConfig { + required USVString accounts_endpoint; + required USVString client_metadata_endpoint; + required USVString id_assertion_endpoint; + IdentityProviderBranding branding; +}; + +dictionary IdentityProviderAccount { + required USVString id; + required USVString name; + required USVString email; + USVString given_name; + USVString picture; + sequence approved_clients; +}; +dictionary IdentityProviderAccountList { + sequence accounts; +}; + +dictionary IdentityProviderToken { + required USVString token; +}; + +dictionary IdentityProviderClientMetadata { + USVString privacy_policy_url; + USVString terms_of_service_url; +}; diff --git a/test/wpt/tests/interfaces/FileAPI.idl b/test/wpt/tests/interfaces/FileAPI.idl new file mode 100644 index 0000000..aee0e65 --- /dev/null +++ b/test/wpt/tests/interfaces/FileAPI.idl @@ -0,0 +1,100 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: File API (https://w3c.github.io/FileAPI/) + +[Exposed=(Window,Worker), Serializable] +interface Blob { + constructor(optional sequence blobParts, + optional BlobPropertyBag options = {}); + + readonly attribute unsigned long long size; + readonly attribute DOMString type; + + // slice Blob into byte-ranged chunks + Blob slice(optional [Clamp] long long start, + optional [Clamp] long long end, + optional DOMString contentType); + + // read from the Blob. + [NewObject] ReadableStream stream(); + [NewObject] Promise text(); + [NewObject] Promise arrayBuffer(); +}; + +enum EndingType { "transparent", "native" }; + +dictionary BlobPropertyBag { + DOMString type = ""; + EndingType endings = "transparent"; +}; + +typedef (BufferSource or Blob or USVString) BlobPart; + +[Exposed=(Window,Worker), Serializable] +interface File : Blob { + constructor(sequence fileBits, + USVString fileName, + optional FilePropertyBag options = {}); + readonly attribute DOMString name; + readonly attribute long long lastModified; +}; + +dictionary FilePropertyBag : BlobPropertyBag { + long long lastModified; +}; + +[Exposed=(Window,Worker), Serializable] +interface FileList { + getter File? item(unsigned long index); + readonly attribute unsigned long length; +}; + +[Exposed=(Window,Worker)] +interface FileReader: EventTarget { + constructor(); + // async read methods + undefined readAsArrayBuffer(Blob blob); + undefined readAsBinaryString(Blob blob); + undefined readAsText(Blob blob, optional DOMString encoding); + undefined readAsDataURL(Blob blob); + + undefined abort(); + + // states + const unsigned short EMPTY = 0; + const unsigned short LOADING = 1; + const unsigned short DONE = 2; + + readonly attribute unsigned short readyState; + + // File or Blob data + readonly attribute (DOMString or ArrayBuffer)? result; + + readonly attribute DOMException? error; + + // event handler content attributes + attribute EventHandler onloadstart; + attribute EventHandler onprogress; + attribute EventHandler onload; + attribute EventHandler onabort; + attribute EventHandler onerror; + attribute EventHandler onloadend; +}; + +[Exposed=(DedicatedWorker,SharedWorker)] +interface FileReaderSync { + constructor(); + // Synchronously return strings + + ArrayBuffer readAsArrayBuffer(Blob blob); + DOMString readAsBinaryString(Blob blob); + DOMString readAsText(Blob blob, optional DOMString encoding); + DOMString readAsDataURL(Blob blob); +}; + +[Exposed=(Window,DedicatedWorker,SharedWorker)] +partial interface URL { + static DOMString createObjectURL((Blob or MediaSource) obj); + static undefined revokeObjectURL(DOMString url); +}; diff --git a/test/wpt/tests/interfaces/IndexedDB.idl b/test/wpt/tests/interfaces/IndexedDB.idl new file mode 100644 index 0000000..d82391d --- /dev/null +++ b/test/wpt/tests/interfaces/IndexedDB.idl @@ -0,0 +1,226 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Indexed Database API 3.0 (https://w3c.github.io/IndexedDB/) + +[Exposed=(Window,Worker)] +interface IDBRequest : EventTarget { + readonly attribute any result; + readonly attribute DOMException? error; + readonly attribute (IDBObjectStore or IDBIndex or IDBCursor)? source; + readonly attribute IDBTransaction? transaction; + readonly attribute IDBRequestReadyState readyState; + + // Event handlers: + attribute EventHandler onsuccess; + attribute EventHandler onerror; +}; + +enum IDBRequestReadyState { + "pending", + "done" +}; + +[Exposed=(Window,Worker)] +interface IDBOpenDBRequest : IDBRequest { + // Event handlers: + attribute EventHandler onblocked; + attribute EventHandler onupgradeneeded; +}; + +[Exposed=(Window,Worker)] +interface IDBVersionChangeEvent : Event { + constructor(DOMString type, optional IDBVersionChangeEventInit eventInitDict = {}); + readonly attribute unsigned long long oldVersion; + readonly attribute unsigned long long? newVersion; +}; + +dictionary IDBVersionChangeEventInit : EventInit { + unsigned long long oldVersion = 0; + unsigned long long? newVersion = null; +}; + +partial interface mixin WindowOrWorkerGlobalScope { + [SameObject] readonly attribute IDBFactory indexedDB; +}; + +[Exposed=(Window,Worker)] +interface IDBFactory { + [NewObject] IDBOpenDBRequest open(DOMString name, + optional [EnforceRange] unsigned long long version); + [NewObject] IDBOpenDBRequest deleteDatabase(DOMString name); + + Promise> databases(); + + short cmp(any first, any second); +}; + +dictionary IDBDatabaseInfo { + DOMString name; + unsigned long long version; +}; + +[Exposed=(Window,Worker)] +interface IDBDatabase : EventTarget { + readonly attribute DOMString name; + readonly attribute unsigned long long version; + readonly attribute DOMStringList objectStoreNames; + + [NewObject] IDBTransaction transaction((DOMString or sequence) storeNames, + optional IDBTransactionMode mode = "readonly", + optional IDBTransactionOptions options = {}); + undefined close(); + + [NewObject] IDBObjectStore createObjectStore( + DOMString name, + optional IDBObjectStoreParameters options = {}); + undefined deleteObjectStore(DOMString name); + + // Event handlers: + attribute EventHandler onabort; + attribute EventHandler onclose; + attribute EventHandler onerror; + attribute EventHandler onversionchange; +}; + +enum IDBTransactionDurability { "default", "strict", "relaxed" }; + +dictionary IDBTransactionOptions { + IDBTransactionDurability durability = "default"; +}; + +dictionary IDBObjectStoreParameters { + (DOMString or sequence)? keyPath = null; + boolean autoIncrement = false; +}; + +[Exposed=(Window,Worker)] +interface IDBObjectStore { + attribute DOMString name; + readonly attribute any keyPath; + readonly attribute DOMStringList indexNames; + [SameObject] readonly attribute IDBTransaction transaction; + readonly attribute boolean autoIncrement; + + [NewObject] IDBRequest put(any value, optional any key); + [NewObject] IDBRequest add(any value, optional any key); + [NewObject] IDBRequest delete(any query); + [NewObject] IDBRequest clear(); + [NewObject] IDBRequest get(any query); + [NewObject] IDBRequest getKey(any query); + [NewObject] IDBRequest getAll(optional any query, + optional [EnforceRange] unsigned long count); + [NewObject] IDBRequest getAllKeys(optional any query, + optional [EnforceRange] unsigned long count); + [NewObject] IDBRequest count(optional any query); + + [NewObject] IDBRequest openCursor(optional any query, + optional IDBCursorDirection direction = "next"); + [NewObject] IDBRequest openKeyCursor(optional any query, + optional IDBCursorDirection direction = "next"); + + IDBIndex index(DOMString name); + + [NewObject] IDBIndex createIndex(DOMString name, + (DOMString or sequence) keyPath, + optional IDBIndexParameters options = {}); + undefined deleteIndex(DOMString name); +}; + +dictionary IDBIndexParameters { + boolean unique = false; + boolean multiEntry = false; +}; + +[Exposed=(Window,Worker)] +interface IDBIndex { + attribute DOMString name; + [SameObject] readonly attribute IDBObjectStore objectStore; + readonly attribute any keyPath; + readonly attribute boolean multiEntry; + readonly attribute boolean unique; + + [NewObject] IDBRequest get(any query); + [NewObject] IDBRequest getKey(any query); + [NewObject] IDBRequest getAll(optional any query, + optional [EnforceRange] unsigned long count); + [NewObject] IDBRequest getAllKeys(optional any query, + optional [EnforceRange] unsigned long count); + [NewObject] IDBRequest count(optional any query); + + [NewObject] IDBRequest openCursor(optional any query, + optional IDBCursorDirection direction = "next"); + [NewObject] IDBRequest openKeyCursor(optional any query, + optional IDBCursorDirection direction = "next"); +}; + +[Exposed=(Window,Worker)] +interface IDBKeyRange { + readonly attribute any lower; + readonly attribute any upper; + readonly attribute boolean lowerOpen; + readonly attribute boolean upperOpen; + + // Static construction methods: + [NewObject] static IDBKeyRange only(any value); + [NewObject] static IDBKeyRange lowerBound(any lower, optional boolean open = false); + [NewObject] static IDBKeyRange upperBound(any upper, optional boolean open = false); + [NewObject] static IDBKeyRange bound(any lower, + any upper, + optional boolean lowerOpen = false, + optional boolean upperOpen = false); + + boolean includes(any key); +}; + +[Exposed=(Window,Worker)] +interface IDBCursor { + readonly attribute (IDBObjectStore or IDBIndex) source; + readonly attribute IDBCursorDirection direction; + readonly attribute any key; + readonly attribute any primaryKey; + [SameObject] readonly attribute IDBRequest request; + + undefined advance([EnforceRange] unsigned long count); + undefined continue(optional any key); + undefined continuePrimaryKey(any key, any primaryKey); + + [NewObject] IDBRequest update(any value); + [NewObject] IDBRequest delete(); +}; + +enum IDBCursorDirection { + "next", + "nextunique", + "prev", + "prevunique" +}; + +[Exposed=(Window,Worker)] +interface IDBCursorWithValue : IDBCursor { + readonly attribute any value; +}; + +[Exposed=(Window,Worker)] +interface IDBTransaction : EventTarget { + readonly attribute DOMStringList objectStoreNames; + readonly attribute IDBTransactionMode mode; + readonly attribute IDBTransactionDurability durability; + [SameObject] readonly attribute IDBDatabase db; + readonly attribute DOMException? error; + + IDBObjectStore objectStore(DOMString name); + undefined commit(); + undefined abort(); + + // Event handlers: + attribute EventHandler onabort; + attribute EventHandler oncomplete; + attribute EventHandler onerror; +}; + +enum IDBTransactionMode { + "readonly", + "readwrite", + "versionchange" +}; diff --git a/test/wpt/tests/interfaces/KHR_parallel_shader_compile.idl b/test/wpt/tests/interfaces/KHR_parallel_shader_compile.idl new file mode 100644 index 0000000..1470965 --- /dev/null +++ b/test/wpt/tests/interfaces/KHR_parallel_shader_compile.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL KHR_parallel_shader_compile Extension Specification (https://registry.khronos.org/webgl/extensions/KHR_parallel_shader_compile/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface KHR_parallel_shader_compile { + const GLenum COMPLETION_STATUS_KHR = 0x91B1; +}; diff --git a/test/wpt/tests/interfaces/META.yml b/test/wpt/tests/interfaces/META.yml new file mode 100644 index 0000000..c1dd8dd --- /dev/null +++ b/test/wpt/tests/interfaces/META.yml @@ -0,0 +1,2 @@ +suggested_reviewers: + - foolip diff --git a/test/wpt/tests/interfaces/OES_draw_buffers_indexed.idl b/test/wpt/tests/interfaces/OES_draw_buffers_indexed.idl new file mode 100644 index 0000000..ea1e217 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_draw_buffers_indexed.idl @@ -0,0 +1,26 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_draw_buffers_indexed Extension Specification (https://registry.khronos.org/webgl/extensions/OES_draw_buffers_indexed/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_draw_buffers_indexed { + undefined enableiOES(GLenum target, GLuint index); + + undefined disableiOES(GLenum target, GLuint index); + + undefined blendEquationiOES(GLuint buf, GLenum mode); + + undefined blendEquationSeparateiOES(GLuint buf, + GLenum modeRGB, GLenum modeAlpha); + + undefined blendFunciOES(GLuint buf, + GLenum src, GLenum dst); + + undefined blendFuncSeparateiOES(GLuint buf, + GLenum srcRGB, GLenum dstRGB, + GLenum srcAlpha, GLenum dstAlpha); + + undefined colorMaskiOES(GLuint buf, + GLboolean r, GLboolean g, GLboolean b, GLboolean a); +}; diff --git a/test/wpt/tests/interfaces/OES_element_index_uint.idl b/test/wpt/tests/interfaces/OES_element_index_uint.idl new file mode 100644 index 0000000..df43a57 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_element_index_uint.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_element_index_uint Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_element_index_uint/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_element_index_uint { +}; diff --git a/test/wpt/tests/interfaces/OES_fbo_render_mipmap.idl b/test/wpt/tests/interfaces/OES_fbo_render_mipmap.idl new file mode 100644 index 0000000..608c392 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_fbo_render_mipmap.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_fbo_render_mipmap Extension Specification (https://registry.khronos.org/webgl/extensions/OES_fbo_render_mipmap/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_fbo_render_mipmap { +}; diff --git a/test/wpt/tests/interfaces/OES_standard_derivatives.idl b/test/wpt/tests/interfaces/OES_standard_derivatives.idl new file mode 100644 index 0000000..7bf073a --- /dev/null +++ b/test/wpt/tests/interfaces/OES_standard_derivatives.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_standard_derivatives Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_standard_derivatives/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_standard_derivatives { + const GLenum FRAGMENT_SHADER_DERIVATIVE_HINT_OES = 0x8B8B; +}; diff --git a/test/wpt/tests/interfaces/OES_texture_float.idl b/test/wpt/tests/interfaces/OES_texture_float.idl new file mode 100644 index 0000000..a1bb79c --- /dev/null +++ b/test/wpt/tests/interfaces/OES_texture_float.idl @@ -0,0 +1,7 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_texture_float Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_texture_float/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_texture_float { }; diff --git a/test/wpt/tests/interfaces/OES_texture_float_linear.idl b/test/wpt/tests/interfaces/OES_texture_float_linear.idl new file mode 100644 index 0000000..4626297 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_texture_float_linear.idl @@ -0,0 +1,7 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_texture_float_linear Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_texture_float_linear/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_texture_float_linear { }; diff --git a/test/wpt/tests/interfaces/OES_texture_half_float.idl b/test/wpt/tests/interfaces/OES_texture_half_float.idl new file mode 100644 index 0000000..be41454 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_texture_half_float.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_texture_half_float Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_texture_half_float/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_texture_half_float { + const GLenum HALF_FLOAT_OES = 0x8D61; +}; diff --git a/test/wpt/tests/interfaces/OES_texture_half_float_linear.idl b/test/wpt/tests/interfaces/OES_texture_half_float_linear.idl new file mode 100644 index 0000000..2f1a999 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_texture_half_float_linear.idl @@ -0,0 +1,7 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_texture_half_float_linear Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_texture_half_float_linear/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_texture_half_float_linear { }; diff --git a/test/wpt/tests/interfaces/OES_vertex_array_object.idl b/test/wpt/tests/interfaces/OES_vertex_array_object.idl new file mode 100644 index 0000000..8aeb745 --- /dev/null +++ b/test/wpt/tests/interfaces/OES_vertex_array_object.idl @@ -0,0 +1,18 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OES_vertex_array_object Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/OES_vertex_array_object/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WebGLVertexArrayObjectOES : WebGLObject { +}; + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OES_vertex_array_object { + const GLenum VERTEX_ARRAY_BINDING_OES = 0x85B5; + + WebGLVertexArrayObjectOES? createVertexArrayOES(); + undefined deleteVertexArrayOES(WebGLVertexArrayObjectOES? arrayObject); + [WebGLHandlesContextLoss] GLboolean isVertexArrayOES(WebGLVertexArrayObjectOES? arrayObject); + undefined bindVertexArrayOES(WebGLVertexArrayObjectOES? arrayObject); +}; diff --git a/test/wpt/tests/interfaces/OVR_multiview2.idl b/test/wpt/tests/interfaces/OVR_multiview2.idl new file mode 100644 index 0000000..9c1ecc4 --- /dev/null +++ b/test/wpt/tests/interfaces/OVR_multiview2.idl @@ -0,0 +1,14 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL OVR_multiview2 Extension Specification (https://registry.khronos.org/webgl/extensions/OVR_multiview2/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface OVR_multiview2 { + const GLenum FRAMEBUFFER_ATTACHMENT_TEXTURE_NUM_VIEWS_OVR = 0x9630; + const GLenum FRAMEBUFFER_ATTACHMENT_TEXTURE_BASE_VIEW_INDEX_OVR = 0x9632; + const GLenum MAX_VIEWS_OVR = 0x9631; + const GLenum FRAMEBUFFER_INCOMPLETE_VIEW_TARGETS_OVR = 0x9633; + + undefined framebufferTextureMultiviewOVR(GLenum target, GLenum attachment, WebGLTexture? texture, GLint level, GLint baseViewIndex, GLsizei numViews); +}; diff --git a/test/wpt/tests/interfaces/README.md b/test/wpt/tests/interfaces/README.md new file mode 100644 index 0000000..5e948ad --- /dev/null +++ b/test/wpt/tests/interfaces/README.md @@ -0,0 +1,3 @@ +This directory contains [Web IDL](https://webidl.spec.whatwg.org/) interface definitions for use in idlharness.js tests. + +The `.idl` files (except `*.tentative.idl`) are copied from [@webref/idl](https://www.npmjs.com/package/@webref/idl) by a [workflow](https://github.com/web-platform-tests/wpt/blob/master/.github/workflows/interfaces.yml) that tries to sync the files daily. The resulting pull requests require manual review but can be approved/merged by anyone with write access. diff --git a/test/wpt/tests/interfaces/SVG.idl b/test/wpt/tests/interfaces/SVG.idl new file mode 100644 index 0000000..3a0b861 --- /dev/null +++ b/test/wpt/tests/interfaces/SVG.idl @@ -0,0 +1,693 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Scalable Vector Graphics (SVG) 2 (https://svgwg.org/svg2-draft/) + +[Exposed=Window] +interface SVGElement : Element { + + [SameObject] readonly attribute SVGAnimatedString className; + + readonly attribute SVGSVGElement? ownerSVGElement; + readonly attribute SVGElement? viewportElement; +}; + +SVGElement includes GlobalEventHandlers; +SVGElement includes SVGElementInstance; +SVGElement includes HTMLOrSVGElement; + +dictionary SVGBoundingBoxOptions { + boolean fill = true; + boolean stroke = false; + boolean markers = false; + boolean clipped = false; +}; + +[Exposed=Window] +interface SVGGraphicsElement : SVGElement { + [SameObject] readonly attribute SVGAnimatedTransformList transform; + + DOMRect getBBox(optional SVGBoundingBoxOptions options = {}); + DOMMatrix? getCTM(); + DOMMatrix? getScreenCTM(); +}; + +SVGGraphicsElement includes SVGTests; + +[Exposed=Window] +interface SVGGeometryElement : SVGGraphicsElement { + [SameObject] readonly attribute SVGAnimatedNumber pathLength; + + boolean isPointInFill(optional DOMPointInit point = {}); + boolean isPointInStroke(optional DOMPointInit point = {}); + float getTotalLength(); + DOMPoint getPointAtLength(float distance); +}; + +[Exposed=Window] +interface SVGNumber { + attribute float value; +}; + +[Exposed=Window] +interface SVGLength { + + // Length Unit Types + const unsigned short SVG_LENGTHTYPE_UNKNOWN = 0; + const unsigned short SVG_LENGTHTYPE_NUMBER = 1; + const unsigned short SVG_LENGTHTYPE_PERCENTAGE = 2; + const unsigned short SVG_LENGTHTYPE_EMS = 3; + const unsigned short SVG_LENGTHTYPE_EXS = 4; + const unsigned short SVG_LENGTHTYPE_PX = 5; + const unsigned short SVG_LENGTHTYPE_CM = 6; + const unsigned short SVG_LENGTHTYPE_MM = 7; + const unsigned short SVG_LENGTHTYPE_IN = 8; + const unsigned short SVG_LENGTHTYPE_PT = 9; + const unsigned short SVG_LENGTHTYPE_PC = 10; + + readonly attribute unsigned short unitType; + attribute float value; + attribute float valueInSpecifiedUnits; + attribute DOMString valueAsString; + + undefined newValueSpecifiedUnits(unsigned short unitType, float valueInSpecifiedUnits); + undefined convertToSpecifiedUnits(unsigned short unitType); +}; + +[Exposed=Window] +interface SVGAngle { + + // Angle Unit Types + const unsigned short SVG_ANGLETYPE_UNKNOWN = 0; + const unsigned short SVG_ANGLETYPE_UNSPECIFIED = 1; + const unsigned short SVG_ANGLETYPE_DEG = 2; + const unsigned short SVG_ANGLETYPE_RAD = 3; + const unsigned short SVG_ANGLETYPE_GRAD = 4; + + readonly attribute unsigned short unitType; + attribute float value; + attribute float valueInSpecifiedUnits; + attribute DOMString valueAsString; + + undefined newValueSpecifiedUnits(unsigned short unitType, float valueInSpecifiedUnits); + undefined convertToSpecifiedUnits(unsigned short unitType); +}; + +[Exposed=Window] +interface SVGNumberList { + + readonly attribute unsigned long length; + readonly attribute unsigned long numberOfItems; + + undefined clear(); + SVGNumber initialize(SVGNumber newItem); + getter SVGNumber getItem(unsigned long index); + SVGNumber insertItemBefore(SVGNumber newItem, unsigned long index); + SVGNumber replaceItem(SVGNumber newItem, unsigned long index); + SVGNumber removeItem(unsigned long index); + SVGNumber appendItem(SVGNumber newItem); + setter undefined (unsigned long index, SVGNumber newItem); +}; + +[Exposed=Window] +interface SVGLengthList { + + readonly attribute unsigned long length; + readonly attribute unsigned long numberOfItems; + + undefined clear(); + SVGLength initialize(SVGLength newItem); + getter SVGLength getItem(unsigned long index); + SVGLength insertItemBefore(SVGLength newItem, unsigned long index); + SVGLength replaceItem(SVGLength newItem, unsigned long index); + SVGLength removeItem(unsigned long index); + SVGLength appendItem(SVGLength newItem); + setter undefined (unsigned long index, SVGLength newItem); +}; + +[Exposed=Window] +interface SVGStringList { + + readonly attribute unsigned long length; + readonly attribute unsigned long numberOfItems; + + undefined clear(); + DOMString initialize(DOMString newItem); + getter DOMString getItem(unsigned long index); + DOMString insertItemBefore(DOMString newItem, unsigned long index); + DOMString replaceItem(DOMString newItem, unsigned long index); + DOMString removeItem(unsigned long index); + DOMString appendItem(DOMString newItem); + setter undefined (unsigned long index, DOMString newItem); +}; + +[Exposed=Window] +interface SVGAnimatedBoolean { + attribute boolean baseVal; + readonly attribute boolean animVal; +}; + +[Exposed=Window] +interface SVGAnimatedEnumeration { + attribute unsigned short baseVal; + readonly attribute unsigned short animVal; +}; + +[Exposed=Window] +interface SVGAnimatedInteger { + attribute long baseVal; + readonly attribute long animVal; +}; + +[Exposed=Window] +interface SVGAnimatedNumber { + attribute float baseVal; + readonly attribute float animVal; +}; + +[Exposed=Window] +interface SVGAnimatedLength { + [SameObject] readonly attribute SVGLength baseVal; + [SameObject] readonly attribute SVGLength animVal; +}; + +[Exposed=Window] +interface SVGAnimatedAngle { + [SameObject] readonly attribute SVGAngle baseVal; + [SameObject] readonly attribute SVGAngle animVal; +}; + +[Exposed=Window] +interface SVGAnimatedString { + attribute DOMString baseVal; + readonly attribute DOMString animVal; +}; + +[Exposed=Window] +interface SVGAnimatedRect { + [SameObject] readonly attribute DOMRect baseVal; + [SameObject] readonly attribute DOMRectReadOnly animVal; +}; + +[Exposed=Window] +interface SVGAnimatedNumberList { + [SameObject] readonly attribute SVGNumberList baseVal; + [SameObject] readonly attribute SVGNumberList animVal; +}; + +[Exposed=Window] +interface SVGAnimatedLengthList { + [SameObject] readonly attribute SVGLengthList baseVal; + [SameObject] readonly attribute SVGLengthList animVal; +}; + +[Exposed=Window] +interface SVGUnitTypes { + // Unit Types + const unsigned short SVG_UNIT_TYPE_UNKNOWN = 0; + const unsigned short SVG_UNIT_TYPE_USERSPACEONUSE = 1; + const unsigned short SVG_UNIT_TYPE_OBJECTBOUNDINGBOX = 2; +}; + +interface mixin SVGTests { + [SameObject] readonly attribute SVGStringList requiredExtensions; + [SameObject] readonly attribute SVGStringList systemLanguage; +}; + +interface mixin SVGFitToViewBox { + [SameObject] readonly attribute SVGAnimatedRect viewBox; + [SameObject] readonly attribute SVGAnimatedPreserveAspectRatio preserveAspectRatio; +}; + +interface mixin SVGURIReference { + [SameObject] readonly attribute SVGAnimatedString href; +}; + +partial interface Document { + readonly attribute SVGSVGElement? rootElement; +}; + +[Exposed=Window] +interface SVGSVGElement : SVGGraphicsElement { + + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; + + attribute float currentScale; + [SameObject] readonly attribute DOMPointReadOnly currentTranslate; + + NodeList getIntersectionList(DOMRectReadOnly rect, SVGElement? referenceElement); + NodeList getEnclosureList(DOMRectReadOnly rect, SVGElement? referenceElement); + boolean checkIntersection(SVGElement element, DOMRectReadOnly rect); + boolean checkEnclosure(SVGElement element, DOMRectReadOnly rect); + + undefined deselectAll(); + + SVGNumber createSVGNumber(); + SVGLength createSVGLength(); + SVGAngle createSVGAngle(); + DOMPoint createSVGPoint(); + DOMMatrix createSVGMatrix(); + DOMRect createSVGRect(); + SVGTransform createSVGTransform(); + SVGTransform createSVGTransformFromMatrix(optional DOMMatrix2DInit matrix = {}); + + Element getElementById(DOMString elementId); + + // Deprecated methods that have no effect when called, + // but which are kept for compatibility reasons. + unsigned long suspendRedraw(unsigned long maxWaitMilliseconds); + undefined unsuspendRedraw(unsigned long suspendHandleID); + undefined unsuspendRedrawAll(); + undefined forceRedraw(); +}; + +SVGSVGElement includes SVGFitToViewBox; +SVGSVGElement includes WindowEventHandlers; + +[Exposed=Window] +interface SVGGElement : SVGGraphicsElement { +}; + +[Exposed=Window] +interface SVGDefsElement : SVGGraphicsElement { +}; + +[Exposed=Window] +interface SVGDescElement : SVGElement { +}; + +[Exposed=Window] +interface SVGMetadataElement : SVGElement { +}; + +[Exposed=Window] +interface SVGTitleElement : SVGElement { +}; + +[Exposed=Window] +interface SVGSymbolElement : SVGGraphicsElement { +}; + +SVGSymbolElement includes SVGFitToViewBox; + +[Exposed=Window] +interface SVGUseElement : SVGGraphicsElement { + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; + [SameObject] readonly attribute SVGElement? instanceRoot; + [SameObject] readonly attribute SVGElement? animatedInstanceRoot; +}; + +SVGUseElement includes SVGURIReference; + +[Exposed=Window] +interface SVGUseElementShadowRoot : ShadowRoot { +}; + +interface mixin SVGElementInstance { + [SameObject] readonly attribute SVGElement? correspondingElement; + [SameObject] readonly attribute SVGUseElement? correspondingUseElement; +}; + +[Exposed=Window] +interface ShadowAnimation : Animation { + constructor(Animation source, (Element or CSSPseudoElement) newTarget); + [SameObject] readonly attribute Animation sourceAnimation; +}; + +[Exposed=Window] +interface SVGSwitchElement : SVGGraphicsElement { +}; + +interface mixin GetSVGDocument { + Document getSVGDocument(); +}; + +[Exposed=Window] +interface SVGStyleElement : SVGElement { + attribute DOMString type; + attribute DOMString media; + attribute DOMString title; +}; + +SVGStyleElement includes LinkStyle; + +[Exposed=Window] +interface SVGTransform { + + // Transform Types + const unsigned short SVG_TRANSFORM_UNKNOWN = 0; + const unsigned short SVG_TRANSFORM_MATRIX = 1; + const unsigned short SVG_TRANSFORM_TRANSLATE = 2; + const unsigned short SVG_TRANSFORM_SCALE = 3; + const unsigned short SVG_TRANSFORM_ROTATE = 4; + const unsigned short SVG_TRANSFORM_SKEWX = 5; + const unsigned short SVG_TRANSFORM_SKEWY = 6; + + readonly attribute unsigned short type; + [SameObject] readonly attribute DOMMatrix matrix; + readonly attribute float angle; + + undefined setMatrix(optional DOMMatrix2DInit matrix = {}); + undefined setTranslate(float tx, float ty); + undefined setScale(float sx, float sy); + undefined setRotate(float angle, float cx, float cy); + undefined setSkewX(float angle); + undefined setSkewY(float angle); +}; + +[Exposed=Window] +interface SVGTransformList { + + readonly attribute unsigned long length; + readonly attribute unsigned long numberOfItems; + + undefined clear(); + SVGTransform initialize(SVGTransform newItem); + getter SVGTransform getItem(unsigned long index); + SVGTransform insertItemBefore(SVGTransform newItem, unsigned long index); + SVGTransform replaceItem(SVGTransform newItem, unsigned long index); + SVGTransform removeItem(unsigned long index); + SVGTransform appendItem(SVGTransform newItem); + setter undefined (unsigned long index, SVGTransform newItem); + + // Additional methods not common to other list interfaces. + SVGTransform createSVGTransformFromMatrix(optional DOMMatrix2DInit matrix = {}); + SVGTransform? consolidate(); +}; + +[Exposed=Window] +interface SVGAnimatedTransformList { + [SameObject] readonly attribute SVGTransformList baseVal; + [SameObject] readonly attribute SVGTransformList animVal; +}; + +[Exposed=Window] +interface SVGPreserveAspectRatio { + + // Alignment Types + const unsigned short SVG_PRESERVEASPECTRATIO_UNKNOWN = 0; + const unsigned short SVG_PRESERVEASPECTRATIO_NONE = 1; + const unsigned short SVG_PRESERVEASPECTRATIO_XMINYMIN = 2; + const unsigned short SVG_PRESERVEASPECTRATIO_XMIDYMIN = 3; + const unsigned short SVG_PRESERVEASPECTRATIO_XMAXYMIN = 4; + const unsigned short SVG_PRESERVEASPECTRATIO_XMINYMID = 5; + const unsigned short SVG_PRESERVEASPECTRATIO_XMIDYMID = 6; + const unsigned short SVG_PRESERVEASPECTRATIO_XMAXYMID = 7; + const unsigned short SVG_PRESERVEASPECTRATIO_XMINYMAX = 8; + const unsigned short SVG_PRESERVEASPECTRATIO_XMIDYMAX = 9; + const unsigned short SVG_PRESERVEASPECTRATIO_XMAXYMAX = 10; + + // Meet-or-slice Types + const unsigned short SVG_MEETORSLICE_UNKNOWN = 0; + const unsigned short SVG_MEETORSLICE_MEET = 1; + const unsigned short SVG_MEETORSLICE_SLICE = 2; + + attribute unsigned short align; + attribute unsigned short meetOrSlice; +}; + +[Exposed=Window] +interface SVGAnimatedPreserveAspectRatio { + [SameObject] readonly attribute SVGPreserveAspectRatio baseVal; + [SameObject] readonly attribute SVGPreserveAspectRatio animVal; +}; + +[Exposed=Window] +interface SVGPathElement : SVGGeometryElement { +}; + +[Exposed=Window] +interface SVGRectElement : SVGGeometryElement { + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; + [SameObject] readonly attribute SVGAnimatedLength rx; + [SameObject] readonly attribute SVGAnimatedLength ry; +}; + +[Exposed=Window] +interface SVGCircleElement : SVGGeometryElement { + [SameObject] readonly attribute SVGAnimatedLength cx; + [SameObject] readonly attribute SVGAnimatedLength cy; + [SameObject] readonly attribute SVGAnimatedLength r; +}; + +[Exposed=Window] +interface SVGEllipseElement : SVGGeometryElement { + [SameObject] readonly attribute SVGAnimatedLength cx; + [SameObject] readonly attribute SVGAnimatedLength cy; + [SameObject] readonly attribute SVGAnimatedLength rx; + [SameObject] readonly attribute SVGAnimatedLength ry; +}; + +[Exposed=Window] +interface SVGLineElement : SVGGeometryElement { + [SameObject] readonly attribute SVGAnimatedLength x1; + [SameObject] readonly attribute SVGAnimatedLength y1; + [SameObject] readonly attribute SVGAnimatedLength x2; + [SameObject] readonly attribute SVGAnimatedLength y2; +}; + +interface mixin SVGAnimatedPoints { + [SameObject] readonly attribute SVGPointList points; + [SameObject] readonly attribute SVGPointList animatedPoints; +}; + +[Exposed=Window] +interface SVGPointList { + + readonly attribute unsigned long length; + readonly attribute unsigned long numberOfItems; + + undefined clear(); + DOMPoint initialize(DOMPoint newItem); + getter DOMPoint getItem(unsigned long index); + DOMPoint insertItemBefore(DOMPoint newItem, unsigned long index); + DOMPoint replaceItem(DOMPoint newItem, unsigned long index); + DOMPoint removeItem(unsigned long index); + DOMPoint appendItem(DOMPoint newItem); + setter undefined (unsigned long index, DOMPoint newItem); +}; + +[Exposed=Window] +interface SVGPolylineElement : SVGGeometryElement { +}; + +SVGPolylineElement includes SVGAnimatedPoints; + +[Exposed=Window] +interface SVGPolygonElement : SVGGeometryElement { +}; + +SVGPolygonElement includes SVGAnimatedPoints; + +[Exposed=Window] +interface SVGTextContentElement : SVGGraphicsElement { + + // lengthAdjust Types + const unsigned short LENGTHADJUST_UNKNOWN = 0; + const unsigned short LENGTHADJUST_SPACING = 1; + const unsigned short LENGTHADJUST_SPACINGANDGLYPHS = 2; + + [SameObject] readonly attribute SVGAnimatedLength textLength; + [SameObject] readonly attribute SVGAnimatedEnumeration lengthAdjust; + + long getNumberOfChars(); + float getComputedTextLength(); + float getSubStringLength(unsigned long charnum, unsigned long nchars); + DOMPoint getStartPositionOfChar(unsigned long charnum); + DOMPoint getEndPositionOfChar(unsigned long charnum); + DOMRect getExtentOfChar(unsigned long charnum); + float getRotationOfChar(unsigned long charnum); + long getCharNumAtPosition(optional DOMPointInit point = {}); + undefined selectSubString(unsigned long charnum, unsigned long nchars); +}; + +[Exposed=Window] +interface SVGTextPositioningElement : SVGTextContentElement { + [SameObject] readonly attribute SVGAnimatedLengthList x; + [SameObject] readonly attribute SVGAnimatedLengthList y; + [SameObject] readonly attribute SVGAnimatedLengthList dx; + [SameObject] readonly attribute SVGAnimatedLengthList dy; + [SameObject] readonly attribute SVGAnimatedNumberList rotate; +}; + +[Exposed=Window] +interface SVGTextElement : SVGTextPositioningElement { +}; + +[Exposed=Window] +interface SVGTSpanElement : SVGTextPositioningElement { +}; + +[Exposed=Window] +interface SVGTextPathElement : SVGTextContentElement { + + // textPath Method Types + const unsigned short TEXTPATH_METHODTYPE_UNKNOWN = 0; + const unsigned short TEXTPATH_METHODTYPE_ALIGN = 1; + const unsigned short TEXTPATH_METHODTYPE_STRETCH = 2; + + // textPath Spacing Types + const unsigned short TEXTPATH_SPACINGTYPE_UNKNOWN = 0; + const unsigned short TEXTPATH_SPACINGTYPE_AUTO = 1; + const unsigned short TEXTPATH_SPACINGTYPE_EXACT = 2; + + [SameObject] readonly attribute SVGAnimatedLength startOffset; + [SameObject] readonly attribute SVGAnimatedEnumeration method; + [SameObject] readonly attribute SVGAnimatedEnumeration spacing; +}; + +SVGTextPathElement includes SVGURIReference; + +[Exposed=Window] +interface SVGImageElement : SVGGraphicsElement { + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; + [SameObject] readonly attribute SVGAnimatedPreserveAspectRatio preserveAspectRatio; + attribute DOMString? crossOrigin; +}; + +SVGImageElement includes SVGURIReference; + +[Exposed=Window] +interface SVGForeignObjectElement : SVGGraphicsElement { + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; +}; + +[Exposed=Window] +interface SVGMarkerElement : SVGElement { + + // Marker Unit Types + const unsigned short SVG_MARKERUNITS_UNKNOWN = 0; + const unsigned short SVG_MARKERUNITS_USERSPACEONUSE = 1; + const unsigned short SVG_MARKERUNITS_STROKEWIDTH = 2; + + // Marker Orientation Types + const unsigned short SVG_MARKER_ORIENT_UNKNOWN = 0; + const unsigned short SVG_MARKER_ORIENT_AUTO = 1; + const unsigned short SVG_MARKER_ORIENT_ANGLE = 2; + + [SameObject] readonly attribute SVGAnimatedLength refX; + [SameObject] readonly attribute SVGAnimatedLength refY; + [SameObject] readonly attribute SVGAnimatedEnumeration markerUnits; + [SameObject] readonly attribute SVGAnimatedLength markerWidth; + [SameObject] readonly attribute SVGAnimatedLength markerHeight; + [SameObject] readonly attribute SVGAnimatedEnumeration orientType; + [SameObject] readonly attribute SVGAnimatedAngle orientAngle; + attribute DOMString orient; + + undefined setOrientToAuto(); + undefined setOrientToAngle(SVGAngle angle); +}; + +SVGMarkerElement includes SVGFitToViewBox; + +[Exposed=Window] +interface SVGGradientElement : SVGElement { + + // Spread Method Types + const unsigned short SVG_SPREADMETHOD_UNKNOWN = 0; + const unsigned short SVG_SPREADMETHOD_PAD = 1; + const unsigned short SVG_SPREADMETHOD_REFLECT = 2; + const unsigned short SVG_SPREADMETHOD_REPEAT = 3; + + [SameObject] readonly attribute SVGAnimatedEnumeration gradientUnits; + [SameObject] readonly attribute SVGAnimatedTransformList gradientTransform; + [SameObject] readonly attribute SVGAnimatedEnumeration spreadMethod; +}; + +SVGGradientElement includes SVGURIReference; + +[Exposed=Window] +interface SVGLinearGradientElement : SVGGradientElement { + [SameObject] readonly attribute SVGAnimatedLength x1; + [SameObject] readonly attribute SVGAnimatedLength y1; + [SameObject] readonly attribute SVGAnimatedLength x2; + [SameObject] readonly attribute SVGAnimatedLength y2; +}; + +[Exposed=Window] +interface SVGRadialGradientElement : SVGGradientElement { + [SameObject] readonly attribute SVGAnimatedLength cx; + [SameObject] readonly attribute SVGAnimatedLength cy; + [SameObject] readonly attribute SVGAnimatedLength r; + [SameObject] readonly attribute SVGAnimatedLength fx; + [SameObject] readonly attribute SVGAnimatedLength fy; + [SameObject] readonly attribute SVGAnimatedLength fr; +}; + +[Exposed=Window] +interface SVGStopElement : SVGElement { + [SameObject] readonly attribute SVGAnimatedNumber offset; +}; + +[Exposed=Window] +interface SVGPatternElement : SVGElement { + [SameObject] readonly attribute SVGAnimatedEnumeration patternUnits; + [SameObject] readonly attribute SVGAnimatedEnumeration patternContentUnits; + [SameObject] readonly attribute SVGAnimatedTransformList patternTransform; + [SameObject] readonly attribute SVGAnimatedLength x; + [SameObject] readonly attribute SVGAnimatedLength y; + [SameObject] readonly attribute SVGAnimatedLength width; + [SameObject] readonly attribute SVGAnimatedLength height; +}; + +SVGPatternElement includes SVGFitToViewBox; +SVGPatternElement includes SVGURIReference; + +[Exposed=Window] +interface SVGScriptElement : SVGElement { + attribute DOMString type; + attribute DOMString? crossOrigin; +}; + +SVGScriptElement includes SVGURIReference; + +[Exposed=Window] +interface SVGAElement : SVGGraphicsElement { + [SameObject] readonly attribute SVGAnimatedString target; + attribute DOMString download; + attribute USVString ping; + attribute DOMString rel; + [SameObject, PutForwards=value] readonly attribute DOMTokenList relList; + attribute DOMString hreflang; + attribute DOMString type; + + attribute DOMString text; + + attribute DOMString referrerPolicy; +}; + +SVGAElement includes SVGURIReference; + +// Inline HTMLHyperlinkElementUtils except href, which conflicts. +partial interface SVGAElement { + readonly attribute USVString origin; + [CEReactions] attribute USVString protocol; + [CEReactions] attribute USVString username; + [CEReactions] attribute USVString password; + [CEReactions] attribute USVString host; + [CEReactions] attribute USVString hostname; + [CEReactions] attribute USVString port; + [CEReactions] attribute USVString pathname; + [CEReactions] attribute USVString search; + [CEReactions] attribute USVString hash; +}; + +[Exposed=Window] +interface SVGViewElement : SVGElement {}; + +SVGViewElement includes SVGFitToViewBox; diff --git a/test/wpt/tests/interfaces/WEBGL_blend_equation_advanced_coherent.idl b/test/wpt/tests/interfaces/WEBGL_blend_equation_advanced_coherent.idl new file mode 100644 index 0000000..2208329 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_blend_equation_advanced_coherent.idl @@ -0,0 +1,23 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_blend_equation_advanced_coherent Extension Draft Specification (https://registry.khronos.org/webgl/extensions/WEBGL_blend_equation_advanced_coherent/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_blend_equation_advanced_coherent { + const GLenum MULTIPLY = 0x9294; + const GLenum SCREEN = 0x9295; + const GLenum OVERLAY = 0x9296; + const GLenum DARKEN = 0x9297; + const GLenum LIGHTEN = 0x9298; + const GLenum COLORDODGE = 0x9299; + const GLenum COLORBURN = 0x929A; + const GLenum HARDLIGHT = 0x929B; + const GLenum SOFTLIGHT = 0x929C; + const GLenum DIFFERENCE = 0x929E; + const GLenum EXCLUSION = 0x92A0; + const GLenum HSL_HUE = 0x92AD; + const GLenum HSL_SATURATION = 0x92AE; + const GLenum HSL_COLOR = 0x92AF; + const GLenum HSL_LUMINOSITY = 0x92B0; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_clip_cull_distance.idl b/test/wpt/tests/interfaces/WEBGL_clip_cull_distance.idl new file mode 100644 index 0000000..46fa921 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_clip_cull_distance.idl @@ -0,0 +1,20 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_clip_cull_distance Extension Draft Specification (https://registry.khronos.org/webgl/extensions/WEBGL_clip_cull_distance/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_clip_cull_distance { + const GLenum MAX_CLIP_DISTANCES_WEBGL = 0x0D32; + const GLenum MAX_CULL_DISTANCES_WEBGL = 0x82F9; + const GLenum MAX_COMBINED_CLIP_AND_CULL_DISTANCES_WEBGL = 0x82FA; + + const GLenum CLIP_DISTANCE0_WEBGL = 0x3000; + const GLenum CLIP_DISTANCE1_WEBGL = 0x3001; + const GLenum CLIP_DISTANCE2_WEBGL = 0x3002; + const GLenum CLIP_DISTANCE3_WEBGL = 0x3003; + const GLenum CLIP_DISTANCE4_WEBGL = 0x3004; + const GLenum CLIP_DISTANCE5_WEBGL = 0x3005; + const GLenum CLIP_DISTANCE6_WEBGL = 0x3006; + const GLenum CLIP_DISTANCE7_WEBGL = 0x3007; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_color_buffer_float.idl b/test/wpt/tests/interfaces/WEBGL_color_buffer_float.idl new file mode 100644 index 0000000..b73f631 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_color_buffer_float.idl @@ -0,0 +1,11 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_color_buffer_float Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_color_buffer_float/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_color_buffer_float { + const GLenum RGBA32F_EXT = 0x8814; + const GLenum FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE_EXT = 0x8211; + const GLenum UNSIGNED_NORMALIZED_EXT = 0x8C17; +}; // interface WEBGL_color_buffer_float diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_astc.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_astc.idl new file mode 100644 index 0000000..9e4632f --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_astc.idl @@ -0,0 +1,41 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_astc Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_astc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_astc { + /* Compressed Texture Format */ + const GLenum COMPRESSED_RGBA_ASTC_4x4_KHR = 0x93B0; + const GLenum COMPRESSED_RGBA_ASTC_5x4_KHR = 0x93B1; + const GLenum COMPRESSED_RGBA_ASTC_5x5_KHR = 0x93B2; + const GLenum COMPRESSED_RGBA_ASTC_6x5_KHR = 0x93B3; + const GLenum COMPRESSED_RGBA_ASTC_6x6_KHR = 0x93B4; + const GLenum COMPRESSED_RGBA_ASTC_8x5_KHR = 0x93B5; + const GLenum COMPRESSED_RGBA_ASTC_8x6_KHR = 0x93B6; + const GLenum COMPRESSED_RGBA_ASTC_8x8_KHR = 0x93B7; + const GLenum COMPRESSED_RGBA_ASTC_10x5_KHR = 0x93B8; + const GLenum COMPRESSED_RGBA_ASTC_10x6_KHR = 0x93B9; + const GLenum COMPRESSED_RGBA_ASTC_10x8_KHR = 0x93BA; + const GLenum COMPRESSED_RGBA_ASTC_10x10_KHR = 0x93BB; + const GLenum COMPRESSED_RGBA_ASTC_12x10_KHR = 0x93BC; + const GLenum COMPRESSED_RGBA_ASTC_12x12_KHR = 0x93BD; + + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR = 0x93D0; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR = 0x93D1; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR = 0x93D2; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR = 0x93D3; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR = 0x93D4; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR = 0x93D5; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR = 0x93D6; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR = 0x93D7; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_10x5_KHR = 0x93D8; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_10x6_KHR = 0x93D9; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_10x8_KHR = 0x93DA; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_10x10_KHR = 0x93DB; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_12x10_KHR = 0x93DC; + const GLenum COMPRESSED_SRGB8_ALPHA8_ASTC_12x12_KHR = 0x93DD; + + // Profile query support. + sequence getSupportedProfiles(); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc.idl new file mode 100644 index 0000000..5174a08 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc.idl @@ -0,0 +1,19 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_etc Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_etc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_etc { + /* Compressed Texture Formats */ + const GLenum COMPRESSED_R11_EAC = 0x9270; + const GLenum COMPRESSED_SIGNED_R11_EAC = 0x9271; + const GLenum COMPRESSED_RG11_EAC = 0x9272; + const GLenum COMPRESSED_SIGNED_RG11_EAC = 0x9273; + const GLenum COMPRESSED_RGB8_ETC2 = 0x9274; + const GLenum COMPRESSED_SRGB8_ETC2 = 0x9275; + const GLenum COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9276; + const GLenum COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9277; + const GLenum COMPRESSED_RGBA8_ETC2_EAC = 0x9278; + const GLenum COMPRESSED_SRGB8_ALPHA8_ETC2_EAC = 0x9279; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc1.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc1.idl new file mode 100644 index 0000000..773697e --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_etc1.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_etc1 Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_etc1/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_etc1 { + /* Compressed Texture Format */ + const GLenum COMPRESSED_RGB_ETC1_WEBGL = 0x8D64; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_pvrtc.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_pvrtc.idl new file mode 100644 index 0000000..5aa004a --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_pvrtc.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_pvrtc Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_pvrtc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_pvrtc { + /* Compressed Texture Formats */ + const GLenum COMPRESSED_RGB_PVRTC_4BPPV1_IMG = 0x8C00; + const GLenum COMPRESSED_RGB_PVRTC_2BPPV1_IMG = 0x8C01; + const GLenum COMPRESSED_RGBA_PVRTC_4BPPV1_IMG = 0x8C02; + const GLenum COMPRESSED_RGBA_PVRTC_2BPPV1_IMG = 0x8C03; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc.idl new file mode 100644 index 0000000..6e7c4bd --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_s3tc Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_s3tc/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_s3tc { + /* Compressed Texture Formats */ + const GLenum COMPRESSED_RGB_S3TC_DXT1_EXT = 0x83F0; + const GLenum COMPRESSED_RGBA_S3TC_DXT1_EXT = 0x83F1; + const GLenum COMPRESSED_RGBA_S3TC_DXT3_EXT = 0x83F2; + const GLenum COMPRESSED_RGBA_S3TC_DXT5_EXT = 0x83F3; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc_srgb.idl b/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc_srgb.idl new file mode 100644 index 0000000..809265e --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_compressed_texture_s3tc_srgb.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_compressed_texture_s3tc_srgb Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_compressed_texture_s3tc_srgb/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_compressed_texture_s3tc_srgb { + /* Compressed Texture Formats */ + const GLenum COMPRESSED_SRGB_S3TC_DXT1_EXT = 0x8C4C; + const GLenum COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT = 0x8C4D; + const GLenum COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT = 0x8C4E; + const GLenum COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT = 0x8C4F; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_debug_renderer_info.idl b/test/wpt/tests/interfaces/WEBGL_debug_renderer_info.idl new file mode 100644 index 0000000..7694061 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_debug_renderer_info.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_debug_renderer_info Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_debug_renderer_info/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_debug_renderer_info { + + const GLenum UNMASKED_VENDOR_WEBGL = 0x9245; + const GLenum UNMASKED_RENDERER_WEBGL = 0x9246; + +}; diff --git a/test/wpt/tests/interfaces/WEBGL_debug_shaders.idl b/test/wpt/tests/interfaces/WEBGL_debug_shaders.idl new file mode 100644 index 0000000..ecb48d0 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_debug_shaders.idl @@ -0,0 +1,11 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_debug_shaders Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_debug_shaders/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_debug_shaders { + + DOMString getTranslatedShaderSource(WebGLShader shader); + +}; diff --git a/test/wpt/tests/interfaces/WEBGL_depth_texture.idl b/test/wpt/tests/interfaces/WEBGL_depth_texture.idl new file mode 100644 index 0000000..a9ec791 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_depth_texture.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_depth_texture Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_depth_texture/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_depth_texture { + const GLenum UNSIGNED_INT_24_8_WEBGL = 0x84FA; +}; diff --git a/test/wpt/tests/interfaces/WEBGL_draw_buffers.idl b/test/wpt/tests/interfaces/WEBGL_draw_buffers.idl new file mode 100644 index 0000000..3310388 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_draw_buffers.idl @@ -0,0 +1,46 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_draw_buffers Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_draw_buffers/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_draw_buffers { + const GLenum COLOR_ATTACHMENT0_WEBGL = 0x8CE0; + const GLenum COLOR_ATTACHMENT1_WEBGL = 0x8CE1; + const GLenum COLOR_ATTACHMENT2_WEBGL = 0x8CE2; + const GLenum COLOR_ATTACHMENT3_WEBGL = 0x8CE3; + const GLenum COLOR_ATTACHMENT4_WEBGL = 0x8CE4; + const GLenum COLOR_ATTACHMENT5_WEBGL = 0x8CE5; + const GLenum COLOR_ATTACHMENT6_WEBGL = 0x8CE6; + const GLenum COLOR_ATTACHMENT7_WEBGL = 0x8CE7; + const GLenum COLOR_ATTACHMENT8_WEBGL = 0x8CE8; + const GLenum COLOR_ATTACHMENT9_WEBGL = 0x8CE9; + const GLenum COLOR_ATTACHMENT10_WEBGL = 0x8CEA; + const GLenum COLOR_ATTACHMENT11_WEBGL = 0x8CEB; + const GLenum COLOR_ATTACHMENT12_WEBGL = 0x8CEC; + const GLenum COLOR_ATTACHMENT13_WEBGL = 0x8CED; + const GLenum COLOR_ATTACHMENT14_WEBGL = 0x8CEE; + const GLenum COLOR_ATTACHMENT15_WEBGL = 0x8CEF; + + const GLenum DRAW_BUFFER0_WEBGL = 0x8825; + const GLenum DRAW_BUFFER1_WEBGL = 0x8826; + const GLenum DRAW_BUFFER2_WEBGL = 0x8827; + const GLenum DRAW_BUFFER3_WEBGL = 0x8828; + const GLenum DRAW_BUFFER4_WEBGL = 0x8829; + const GLenum DRAW_BUFFER5_WEBGL = 0x882A; + const GLenum DRAW_BUFFER6_WEBGL = 0x882B; + const GLenum DRAW_BUFFER7_WEBGL = 0x882C; + const GLenum DRAW_BUFFER8_WEBGL = 0x882D; + const GLenum DRAW_BUFFER9_WEBGL = 0x882E; + const GLenum DRAW_BUFFER10_WEBGL = 0x882F; + const GLenum DRAW_BUFFER11_WEBGL = 0x8830; + const GLenum DRAW_BUFFER12_WEBGL = 0x8831; + const GLenum DRAW_BUFFER13_WEBGL = 0x8832; + const GLenum DRAW_BUFFER14_WEBGL = 0x8833; + const GLenum DRAW_BUFFER15_WEBGL = 0x8834; + + const GLenum MAX_COLOR_ATTACHMENTS_WEBGL = 0x8CDF; + const GLenum MAX_DRAW_BUFFERS_WEBGL = 0x8824; + + undefined drawBuffersWEBGL(sequence buffers); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_draw_instanced_base_vertex_base_instance.idl b/test/wpt/tests/interfaces/WEBGL_draw_instanced_base_vertex_base_instance.idl new file mode 100644 index 0000000..38f7a42 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_draw_instanced_base_vertex_base_instance.idl @@ -0,0 +1,14 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_draw_instanced_base_vertex_base_instance Extension Draft Specification (https://registry.khronos.org/webgl/extensions/WEBGL_draw_instanced_base_vertex_base_instance/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_draw_instanced_base_vertex_base_instance { + undefined drawArraysInstancedBaseInstanceWEBGL( + GLenum mode, GLint first, GLsizei count, + GLsizei instanceCount, GLuint baseInstance); + undefined drawElementsInstancedBaseVertexBaseInstanceWEBGL( + GLenum mode, GLsizei count, GLenum type, GLintptr offset, + GLsizei instanceCount, GLint baseVertex, GLuint baseInstance); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_lose_context.idl b/test/wpt/tests/interfaces/WEBGL_lose_context.idl new file mode 100644 index 0000000..ee68fb5 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_lose_context.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_lose_context Khronos Ratified Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_lose_context/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_lose_context { + undefined loseContext(); + undefined restoreContext(); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_multi_draw.idl b/test/wpt/tests/interfaces/WEBGL_multi_draw.idl new file mode 100644 index 0000000..ee8c044 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_multi_draw.idl @@ -0,0 +1,32 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_multi_draw Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_multi_draw/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_multi_draw { + undefined multiDrawArraysWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) firstsList, GLuint firstsOffset, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + GLsizei drawcount); + undefined multiDrawElementsWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + GLenum type, + ([AllowShared] Int32Array or sequence) offsetsList, GLuint offsetsOffset, + GLsizei drawcount); + undefined multiDrawArraysInstancedWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) firstsList, GLuint firstsOffset, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + ([AllowShared] Int32Array or sequence) instanceCountsList, GLuint instanceCountsOffset, + GLsizei drawcount); + undefined multiDrawElementsInstancedWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + GLenum type, + ([AllowShared] Int32Array or sequence) offsetsList, GLuint offsetsOffset, + ([AllowShared] Int32Array or sequence) instanceCountsList, GLuint instanceCountsOffset, + GLsizei drawcount); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_multi_draw_instanced_base_vertex_base_instance.idl b/test/wpt/tests/interfaces/WEBGL_multi_draw_instanced_base_vertex_base_instance.idl new file mode 100644 index 0000000..2258fa9 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_multi_draw_instanced_base_vertex_base_instance.idl @@ -0,0 +1,26 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_multi_draw_instanced_base_vertex_base_instance Extension Draft Specification (https://registry.khronos.org/webgl/extensions/WEBGL_multi_draw_instanced_base_vertex_base_instance/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_multi_draw_instanced_base_vertex_base_instance { + undefined multiDrawArraysInstancedBaseInstanceWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) firstsList, GLuint firstsOffset, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + ([AllowShared] Int32Array or sequence) instanceCountsList, GLuint instanceCountsOffset, + ([AllowShared] Uint32Array or sequence) baseInstancesList, GLuint baseInstancesOffset, + GLsizei drawcount + ); + undefined multiDrawElementsInstancedBaseVertexBaseInstanceWEBGL( + GLenum mode, + ([AllowShared] Int32Array or sequence) countsList, GLuint countsOffset, + GLenum type, + ([AllowShared] Int32Array or sequence) offsetsList, GLuint offsetsOffset, + ([AllowShared] Int32Array or sequence) instanceCountsList, GLuint instanceCountsOffset, + ([AllowShared] Int32Array or sequence) baseVerticesList, GLuint baseVerticesOffset, + ([AllowShared] Uint32Array or sequence) baseInstancesList, GLuint baseInstancesOffset, + GLsizei drawcount + ); +}; diff --git a/test/wpt/tests/interfaces/WEBGL_provoking_vertex.idl b/test/wpt/tests/interfaces/WEBGL_provoking_vertex.idl new file mode 100644 index 0000000..035e1d2 --- /dev/null +++ b/test/wpt/tests/interfaces/WEBGL_provoking_vertex.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebGL WEBGL_provoking_vertex Extension Specification (https://registry.khronos.org/webgl/extensions/WEBGL_provoking_vertex/) + +[Exposed=(Window,Worker), LegacyNoInterfaceObject] +interface WEBGL_provoking_vertex { + const GLenum FIRST_VERTEX_CONVENTION_WEBGL = 0x8E4D; + const GLenum LAST_VERTEX_CONVENTION_WEBGL = 0x8E4E; // default + const GLenum PROVOKING_VERTEX_WEBGL = 0x8E4F; + + undefined provokingVertexWEBGL(GLenum provokeMode); +}; diff --git a/test/wpt/tests/interfaces/WebCryptoAPI.idl b/test/wpt/tests/interfaces/WebCryptoAPI.idl new file mode 100644 index 0000000..0e68ea8 --- /dev/null +++ b/test/wpt/tests/interfaces/WebCryptoAPI.idl @@ -0,0 +1,237 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Web Cryptography API (https://w3c.github.io/webcrypto/) + +partial interface mixin WindowOrWorkerGlobalScope { + [SameObject] readonly attribute Crypto crypto; +}; + +[Exposed=(Window,Worker)] +interface Crypto { + [SecureContext] readonly attribute SubtleCrypto subtle; + ArrayBufferView getRandomValues(ArrayBufferView array); + [SecureContext] DOMString randomUUID(); +}; + +typedef (object or DOMString) AlgorithmIdentifier; + +typedef AlgorithmIdentifier HashAlgorithmIdentifier; + +dictionary Algorithm { + required DOMString name; +}; + +dictionary KeyAlgorithm { + required DOMString name; +}; + +enum KeyType { "public", "private", "secret" }; + +enum KeyUsage { "encrypt", "decrypt", "sign", "verify", "deriveKey", "deriveBits", "wrapKey", "unwrapKey" }; + +[SecureContext,Exposed=(Window,Worker),Serializable] +interface CryptoKey { + readonly attribute KeyType type; + readonly attribute boolean extractable; + readonly attribute object algorithm; + readonly attribute object usages; +}; + +enum KeyFormat { "raw", "spki", "pkcs8", "jwk" }; + +[SecureContext,Exposed=(Window,Worker)] +interface SubtleCrypto { + Promise encrypt(AlgorithmIdentifier algorithm, + CryptoKey key, + BufferSource data); + Promise decrypt(AlgorithmIdentifier algorithm, + CryptoKey key, + BufferSource data); + Promise sign(AlgorithmIdentifier algorithm, + CryptoKey key, + BufferSource data); + Promise verify(AlgorithmIdentifier algorithm, + CryptoKey key, + BufferSource signature, + BufferSource data); + Promise digest(AlgorithmIdentifier algorithm, + BufferSource data); + + Promise generateKey(AlgorithmIdentifier algorithm, + boolean extractable, + sequence keyUsages ); + Promise deriveKey(AlgorithmIdentifier algorithm, + CryptoKey baseKey, + AlgorithmIdentifier derivedKeyType, + boolean extractable, + sequence keyUsages ); + Promise deriveBits(AlgorithmIdentifier algorithm, + CryptoKey baseKey, + unsigned long length); + + Promise importKey(KeyFormat format, + (BufferSource or JsonWebKey) keyData, + AlgorithmIdentifier algorithm, + boolean extractable, + sequence keyUsages ); + Promise exportKey(KeyFormat format, CryptoKey key); + + Promise wrapKey(KeyFormat format, + CryptoKey key, + CryptoKey wrappingKey, + AlgorithmIdentifier wrapAlgorithm); + Promise unwrapKey(KeyFormat format, + BufferSource wrappedKey, + CryptoKey unwrappingKey, + AlgorithmIdentifier unwrapAlgorithm, + AlgorithmIdentifier unwrappedKeyAlgorithm, + boolean extractable, + sequence keyUsages ); +}; + +dictionary RsaOtherPrimesInfo { + // The following fields are defined in Section 6.3.2.7 of JSON Web Algorithms + DOMString r; + DOMString d; + DOMString t; +}; + +dictionary JsonWebKey { + // The following fields are defined in Section 3.1 of JSON Web Key + DOMString kty; + DOMString use; + sequence key_ops; + DOMString alg; + + // The following fields are defined in JSON Web Key Parameters Registration + boolean ext; + + // The following fields are defined in Section 6 of JSON Web Algorithms + DOMString crv; + DOMString x; + DOMString y; + DOMString d; + DOMString n; + DOMString e; + DOMString p; + DOMString q; + DOMString dp; + DOMString dq; + DOMString qi; + sequence oth; + DOMString k; +}; + +typedef Uint8Array BigInteger; + +dictionary CryptoKeyPair { + CryptoKey publicKey; + CryptoKey privateKey; +}; + +dictionary RsaKeyGenParams : Algorithm { + required [EnforceRange] unsigned long modulusLength; + required BigInteger publicExponent; +}; + +dictionary RsaHashedKeyGenParams : RsaKeyGenParams { + required HashAlgorithmIdentifier hash; +}; + +dictionary RsaKeyAlgorithm : KeyAlgorithm { + required unsigned long modulusLength; + required BigInteger publicExponent; +}; + +dictionary RsaHashedKeyAlgorithm : RsaKeyAlgorithm { + required KeyAlgorithm hash; +}; + +dictionary RsaHashedImportParams : Algorithm { + required HashAlgorithmIdentifier hash; +}; + +dictionary RsaPssParams : Algorithm { + required [EnforceRange] unsigned long saltLength; +}; + +dictionary RsaOaepParams : Algorithm { + BufferSource label; +}; + +dictionary EcdsaParams : Algorithm { + required HashAlgorithmIdentifier hash; +}; + +typedef DOMString NamedCurve; + +dictionary EcKeyGenParams : Algorithm { + required NamedCurve namedCurve; +}; + +dictionary EcKeyAlgorithm : KeyAlgorithm { + required NamedCurve namedCurve; +}; + +dictionary EcKeyImportParams : Algorithm { + required NamedCurve namedCurve; +}; + +dictionary EcdhKeyDeriveParams : Algorithm { + required CryptoKey public; +}; + +dictionary AesCtrParams : Algorithm { + required BufferSource counter; + required [EnforceRange] octet length; +}; + +dictionary AesKeyAlgorithm : KeyAlgorithm { + required unsigned short length; +}; + +dictionary AesKeyGenParams : Algorithm { + required [EnforceRange] unsigned short length; +}; + +dictionary AesDerivedKeyParams : Algorithm { + required [EnforceRange] unsigned short length; +}; + +dictionary AesCbcParams : Algorithm { + required BufferSource iv; +}; + +dictionary AesGcmParams : Algorithm { + required BufferSource iv; + BufferSource additionalData; + [EnforceRange] octet tagLength; +}; + +dictionary HmacImportParams : Algorithm { + required HashAlgorithmIdentifier hash; + [EnforceRange] unsigned long length; +}; + +dictionary HmacKeyAlgorithm : KeyAlgorithm { + required KeyAlgorithm hash; + required unsigned long length; +}; + +dictionary HmacKeyGenParams : Algorithm { + required HashAlgorithmIdentifier hash; + [EnforceRange] unsigned long length; +}; + +dictionary HkdfParams : Algorithm { + required HashAlgorithmIdentifier hash; + required BufferSource salt; + required BufferSource info; +}; + +dictionary Pbkdf2Params : Algorithm { + required BufferSource salt; + required [EnforceRange] unsigned long iterations; + required HashAlgorithmIdentifier hash; +}; diff --git a/test/wpt/tests/interfaces/accelerometer.idl b/test/wpt/tests/interfaces/accelerometer.idl new file mode 100644 index 0000000..fc8fc07 --- /dev/null +++ b/test/wpt/tests/interfaces/accelerometer.idl @@ -0,0 +1,40 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Accelerometer (https://w3c.github.io/accelerometer/) + +[SecureContext, Exposed=Window] +interface Accelerometer : Sensor { + constructor(optional AccelerometerSensorOptions options = {}); + readonly attribute double? x; + readonly attribute double? y; + readonly attribute double? z; +}; + +enum AccelerometerLocalCoordinateSystem { "device", "screen" }; + +dictionary AccelerometerSensorOptions : SensorOptions { + AccelerometerLocalCoordinateSystem referenceFrame = "device"; +}; + +[SecureContext, Exposed=Window] +interface LinearAccelerationSensor : Accelerometer { + constructor(optional AccelerometerSensorOptions options = {}); +}; + +[SecureContext, Exposed=Window] +interface GravitySensor : Accelerometer { + constructor(optional AccelerometerSensorOptions options = {}); +}; + +dictionary AccelerometerReadingValues { + required double? x; + required double? y; + required double? z; +}; + +dictionary LinearAccelerationReadingValues : AccelerometerReadingValues { +}; + +dictionary GravityReadingValues : AccelerometerReadingValues { +}; diff --git a/test/wpt/tests/interfaces/ambient-light.idl b/test/wpt/tests/interfaces/ambient-light.idl new file mode 100644 index 0000000..6d9c8e0 --- /dev/null +++ b/test/wpt/tests/interfaces/ambient-light.idl @@ -0,0 +1,14 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Ambient Light Sensor (https://w3c.github.io/ambient-light/) + +[SecureContext, Exposed=Window] +interface AmbientLightSensor : Sensor { + constructor(optional SensorOptions sensorOptions = {}); + readonly attribute double? illuminance; +}; + +dictionary AmbientLightReadingValues { + required double? illuminance; +}; diff --git a/test/wpt/tests/interfaces/anchors.idl b/test/wpt/tests/interfaces/anchors.idl new file mode 100644 index 0000000..d8c5aa6 --- /dev/null +++ b/test/wpt/tests/interfaces/anchors.idl @@ -0,0 +1,37 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: WebXR Anchors Module (https://immersive-web.github.io/anchors/) + +[SecureContext, Exposed=Window] +interface XRAnchor { + readonly attribute XRSpace anchorSpace; + + Promise requestPersistentHandle(); + + undefined delete(); +}; + +partial interface XRFrame { + Promise createAnchor(XRRigidTransform pose, XRSpace space); +}; + +partial interface XRSession { + readonly attribute FrozenArray persistentAnchors; + + Promise restorePersistentAnchor(DOMString uuid); + Promise deletePersistentAnchor(DOMString uuid); +}; + +partial interface XRHitTestResult { + Promise createAnchor(); +}; + +[Exposed=Window] +interface XRAnchorSet { + readonly setlike; +}; + +partial interface XRFrame { + [SameObject] readonly attribute XRAnchorSet trackedAnchors; +}; diff --git a/test/wpt/tests/interfaces/attribution-reporting-api.idl b/test/wpt/tests/interfaces/attribution-reporting-api.idl new file mode 100644 index 0000000..ed4497b --- /dev/null +++ b/test/wpt/tests/interfaces/attribution-reporting-api.idl @@ -0,0 +1,26 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Attribution Reporting (https://wicg.github.io/attribution-reporting-api/) + +interface mixin HTMLAttributionSrcElementUtils { + [CEReactions, SecureContext] attribute USVString attributionSrc; +}; + +HTMLAnchorElement includes HTMLAttributionSrcElementUtils; +HTMLImageElement includes HTMLAttributionSrcElementUtils; +HTMLScriptElement includes HTMLAttributionSrcElementUtils; + +dictionary AttributionReportingRequestOptions { + required boolean eventSourceEligible; + required boolean triggerEligible; +}; + +partial dictionary RequestInit { + AttributionReportingRequestOptions attributionReporting; +}; + +partial interface XMLHttpRequest { + [SecureContext] + undefined setAttributionReporting(AttributionReportingRequestOptions options); +}; diff --git a/test/wpt/tests/interfaces/audio-output.idl b/test/wpt/tests/interfaces/audio-output.idl new file mode 100644 index 0000000..80ceb22 --- /dev/null +++ b/test/wpt/tests/interfaces/audio-output.idl @@ -0,0 +1,17 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Audio Output Devices API (https://w3c.github.io/mediacapture-output/) + +partial interface HTMLMediaElement { + [SecureContext] readonly attribute DOMString sinkId; + [SecureContext] Promise setSinkId (DOMString sinkId); +}; + +partial interface MediaDevices { + Promise selectAudioOutput(optional AudioOutputOptions options = {}); +}; + +dictionary AudioOutputOptions { + DOMString deviceId = ""; +}; diff --git a/test/wpt/tests/interfaces/autoplay-detection.idl b/test/wpt/tests/interfaces/autoplay-detection.idl new file mode 100644 index 0000000..cd0884f --- /dev/null +++ b/test/wpt/tests/interfaces/autoplay-detection.idl @@ -0,0 +1,19 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Autoplay Policy Detection (https://w3c.github.io/autoplay/) + +enum AutoplayPolicy { + "allowed", + "allowed-muted", + "disallowed" +}; + +enum AutoplayPolicyMediaType { "mediaelement", "audiocontext" }; + +[Exposed=Window] +partial interface Navigator { + AutoplayPolicy getAutoplayPolicy(AutoplayPolicyMediaType type); + AutoplayPolicy getAutoplayPolicy(HTMLMediaElement element); + AutoplayPolicy getAutoplayPolicy(AudioContext context); +}; diff --git a/test/wpt/tests/interfaces/background-fetch.idl b/test/wpt/tests/interfaces/background-fetch.idl new file mode 100644 index 0000000..993bd8b --- /dev/null +++ b/test/wpt/tests/interfaces/background-fetch.idl @@ -0,0 +1,89 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Background Fetch (https://wicg.github.io/background-fetch/) + +partial interface ServiceWorkerGlobalScope { + attribute EventHandler onbackgroundfetchsuccess; + attribute EventHandler onbackgroundfetchfail; + attribute EventHandler onbackgroundfetchabort; + attribute EventHandler onbackgroundfetchclick; +}; + +partial interface ServiceWorkerRegistration { + readonly attribute BackgroundFetchManager backgroundFetch; +}; + +[Exposed=(Window,Worker)] +interface BackgroundFetchManager { + Promise fetch(DOMString id, (RequestInfo or sequence) requests, optional BackgroundFetchOptions options = {}); + Promise get(DOMString id); + Promise> getIds(); +}; + +dictionary BackgroundFetchUIOptions { + sequence icons; + DOMString title; +}; + +dictionary BackgroundFetchOptions : BackgroundFetchUIOptions { + unsigned long long downloadTotal = 0; +}; + +[Exposed=(Window,Worker)] +interface BackgroundFetchRegistration : EventTarget { + readonly attribute DOMString id; + readonly attribute unsigned long long uploadTotal; + readonly attribute unsigned long long uploaded; + readonly attribute unsigned long long downloadTotal; + readonly attribute unsigned long long downloaded; + readonly attribute BackgroundFetchResult result; + readonly attribute BackgroundFetchFailureReason failureReason; + readonly attribute boolean recordsAvailable; + + attribute EventHandler onprogress; + + Promise abort(); + Promise match(RequestInfo request, optional CacheQueryOptions options = {}); + Promise> matchAll(optional RequestInfo request, optional CacheQueryOptions options = {}); +}; + +enum BackgroundFetchResult { "", "success", "failure" }; + +enum BackgroundFetchFailureReason { + // The background fetch has not completed yet, or was successful. + "", + // The operation was aborted by the user, or abort() was called. + "aborted", + // A response had a not-ok-status. + "bad-status", + // A fetch failed for other reasons, e.g. CORS, MIX, an invalid partial response, + // or a general network failure for a fetch that cannot be retried. + "fetch-error", + // Storage quota was reached during the operation. + "quota-exceeded", + // The provided downloadTotal was exceeded. + "download-total-exceeded" +}; + +[Exposed=(Window,Worker)] +interface BackgroundFetchRecord { + readonly attribute Request request; + readonly attribute Promise responseReady; +}; + +[Exposed=ServiceWorker] +interface BackgroundFetchEvent : ExtendableEvent { + constructor(DOMString type, BackgroundFetchEventInit init); + readonly attribute BackgroundFetchRegistration registration; +}; + +dictionary BackgroundFetchEventInit : ExtendableEventInit { + required BackgroundFetchRegistration registration; +}; + +[Exposed=ServiceWorker] +interface BackgroundFetchUpdateUIEvent : BackgroundFetchEvent { + constructor(DOMString type, BackgroundFetchEventInit init); + Promise updateUI(optional BackgroundFetchUIOptions options = {}); +}; diff --git a/test/wpt/tests/interfaces/background-sync.idl b/test/wpt/tests/interfaces/background-sync.idl new file mode 100644 index 0000000..79a13a6 --- /dev/null +++ b/test/wpt/tests/interfaces/background-sync.idl @@ -0,0 +1,30 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Web Background Synchronization (https://wicg.github.io/background-sync/spec/) + +partial interface ServiceWorkerRegistration { + readonly attribute SyncManager sync; +}; + +[Exposed=(Window,Worker)] +interface SyncManager { + Promise register(DOMString tag); + Promise> getTags(); +}; + +partial interface ServiceWorkerGlobalScope { + attribute EventHandler onsync; +}; + +[Exposed=ServiceWorker] +interface SyncEvent : ExtendableEvent { + constructor(DOMString type, SyncEventInit init); + readonly attribute DOMString tag; + readonly attribute boolean lastChance; +}; + +dictionary SyncEventInit : ExtendableEventInit { + required DOMString tag; + boolean lastChance = false; +}; diff --git a/test/wpt/tests/interfaces/badging.idl b/test/wpt/tests/interfaces/badging.idl new file mode 100644 index 0000000..8b401e0 --- /dev/null +++ b/test/wpt/tests/interfaces/badging.idl @@ -0,0 +1,15 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Badging API (https://w3c.github.io/badging/) + +[SecureContext] +interface mixin NavigatorBadge { + Promise setAppBadge( + optional [EnforceRange] unsigned long long contents + ); + Promise clearAppBadge(); +}; + +Navigator includes NavigatorBadge; +WorkerNavigator includes NavigatorBadge; diff --git a/test/wpt/tests/interfaces/battery-status.idl b/test/wpt/tests/interfaces/battery-status.idl new file mode 100644 index 0000000..2d042db --- /dev/null +++ b/test/wpt/tests/interfaces/battery-status.idl @@ -0,0 +1,21 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Battery Status API (https://w3c.github.io/battery/) + +[SecureContext] +partial interface Navigator { + Promise getBattery(); +}; + +[SecureContext, Exposed=Window] +interface BatteryManager : EventTarget { + readonly attribute boolean charging; + readonly attribute unrestricted double chargingTime; + readonly attribute unrestricted double dischargingTime; + readonly attribute double level; + attribute EventHandler onchargingchange; + attribute EventHandler onchargingtimechange; + attribute EventHandler ondischargingtimechange; + attribute EventHandler onlevelchange; +}; diff --git a/test/wpt/tests/interfaces/beacon.idl b/test/wpt/tests/interfaces/beacon.idl new file mode 100644 index 0000000..103a999 --- /dev/null +++ b/test/wpt/tests/interfaces/beacon.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Beacon (https://w3c.github.io/beacon/) + +partial interface Navigator { + boolean sendBeacon(USVString url, optional BodyInit? data = null); +}; diff --git a/test/wpt/tests/interfaces/capture-handle-identity.idl b/test/wpt/tests/interfaces/capture-handle-identity.idl new file mode 100644 index 0000000..37b2c61 --- /dev/null +++ b/test/wpt/tests/interfaces/capture-handle-identity.idl @@ -0,0 +1,27 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Capture Handle - Bootstrapping Collaboration when Screensharing (https://w3c.github.io/mediacapture-handle/identity/) + +dictionary CaptureHandleConfig { + boolean exposeOrigin = false; + DOMString handle = ""; + sequence permittedOrigins = []; +}; + +partial interface MediaDevices { + undefined setCaptureHandleConfig(optional CaptureHandleConfig config = {}); +}; + +dictionary CaptureHandle { + DOMString origin; + DOMString handle; +}; + +partial interface MediaStreamTrack { + CaptureHandle? getCaptureHandle(); +}; + +partial interface MediaStreamTrack { + attribute EventHandler oncapturehandlechange; +}; diff --git a/test/wpt/tests/interfaces/captured-mouse-events.tentative.idl b/test/wpt/tests/interfaces/captured-mouse-events.tentative.idl new file mode 100644 index 0000000..7b081cd --- /dev/null +++ b/test/wpt/tests/interfaces/captured-mouse-events.tentative.idl @@ -0,0 +1,25 @@ +// https://screen-share.github.io/mouse-events/ + +enum CaptureStartFocusBehavior { + "focus-captured-surface", + "no-focus-change" +}; + +[Exposed=Window, SecureContext] +interface CaptureController : EventTarget { + constructor(); + undefined setFocusBehavior(CaptureStartFocusBehavior focusBehavior); + attribute EventHandler oncapturedmousechange; +}; + +[Exposed=Window] +interface CapturedMouseEvent : Event { + constructor(DOMString type, optional CapturedMouseEventInit eventInitDict = {}); + readonly attribute long surfaceX; + readonly attribute long surfaceY; +}; + +dictionary CapturedMouseEventInit : EventInit { + long surfaceX = -1; + long surfaceY = -1; +}; diff --git a/test/wpt/tests/interfaces/clipboard-apis.idl b/test/wpt/tests/interfaces/clipboard-apis.idl new file mode 100644 index 0000000..3f2c9ba --- /dev/null +++ b/test/wpt/tests/interfaces/clipboard-apis.idl @@ -0,0 +1,51 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Clipboard API and events (https://w3c.github.io/clipboard-apis/) + +dictionary ClipboardEventInit : EventInit { + DataTransfer? clipboardData = null; +}; + +[Exposed=Window] +interface ClipboardEvent : Event { + constructor(DOMString type, optional ClipboardEventInit eventInitDict = {}); + readonly attribute DataTransfer? clipboardData; +}; + +partial interface Navigator { + [SecureContext, SameObject] readonly attribute Clipboard clipboard; +}; + +typedef Promise<(DOMString or Blob)> ClipboardItemData; + +[SecureContext, Exposed=Window] +interface ClipboardItem { + constructor(record items, + optional ClipboardItemOptions options = {}); + + readonly attribute PresentationStyle presentationStyle; + readonly attribute FrozenArray types; + + Promise getType(DOMString type); +}; + +enum PresentationStyle { "unspecified", "inline", "attachment" }; + +dictionary ClipboardItemOptions { + PresentationStyle presentationStyle = "unspecified"; +}; + +typedef sequence ClipboardItems; + +[SecureContext, Exposed=Window] +interface Clipboard : EventTarget { + Promise read(); + Promise readText(); + Promise write(ClipboardItems data); + Promise writeText(DOMString data); +}; + +dictionary ClipboardPermissionDescriptor : PermissionDescriptor { + boolean allowWithoutGesture = false; +}; diff --git a/test/wpt/tests/interfaces/close-watcher.idl b/test/wpt/tests/interfaces/close-watcher.idl new file mode 100644 index 0000000..de7940c --- /dev/null +++ b/test/wpt/tests/interfaces/close-watcher.idl @@ -0,0 +1,19 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Close Watcher API (https://wicg.github.io/close-watcher/) + +[Exposed=Window] +interface CloseWatcher : EventTarget { + constructor(optional CloseWatcherOptions options = {}); + + undefined destroy(); + undefined close(); + + attribute EventHandler oncancel; + attribute EventHandler onclose; +}; + +dictionary CloseWatcherOptions { + AbortSignal signal; +}; diff --git a/test/wpt/tests/interfaces/compat.idl b/test/wpt/tests/interfaces/compat.idl new file mode 100644 index 0000000..8106c2d --- /dev/null +++ b/test/wpt/tests/interfaces/compat.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Compatibility Standard (https://compat.spec.whatwg.org/) + +partial interface Window { + readonly attribute short orientation; + attribute EventHandler onorientationchange; +}; + +partial interface HTMLBodyElement { + attribute EventHandler onorientationchange; +}; diff --git a/test/wpt/tests/interfaces/compression.idl b/test/wpt/tests/interfaces/compression.idl new file mode 100644 index 0000000..7525d7c --- /dev/null +++ b/test/wpt/tests/interfaces/compression.idl @@ -0,0 +1,22 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Compression Streams (https://wicg.github.io/compression/) + +enum CompressionFormat { + "deflate", + "deflate-raw", + "gzip", +}; + +[Exposed=*] +interface CompressionStream { + constructor(CompressionFormat format); +}; +CompressionStream includes GenericTransformStream; + +[Exposed=*] +interface DecompressionStream { + constructor(CompressionFormat format); +}; +DecompressionStream includes GenericTransformStream; diff --git a/test/wpt/tests/interfaces/compute-pressure.idl b/test/wpt/tests/interfaces/compute-pressure.idl new file mode 100644 index 0000000..3e35dc4 --- /dev/null +++ b/test/wpt/tests/interfaces/compute-pressure.idl @@ -0,0 +1,37 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Compute Pressure Level 1 (https://w3c.github.io/compute-pressure/) + +enum PressureSource { "thermals", "cpu" }; + +enum PressureState { "nominal", "fair", "serious", "critical" }; + +callback PressureUpdateCallback = undefined ( + sequence changes, + PressureObserver observer +); + +[Exposed=(DedicatedWorker,SharedWorker,Window), SecureContext] +interface PressureObserver { + constructor(PressureUpdateCallback callback, optional PressureObserverOptions options = {}); + + Promise observe(PressureSource source); + undefined unobserve(PressureSource source); + undefined disconnect(); + sequence takeRecords(); + + [SameObject] static readonly attribute FrozenArray supportedSources; +}; + +[Exposed=(DedicatedWorker,SharedWorker,Window), SecureContext] +interface PressureRecord { + readonly attribute PressureSource source; + readonly attribute PressureState state; + readonly attribute DOMHighResTimeStamp time; + [Default] object toJSON(); +}; + +dictionary PressureObserverOptions { + double sampleRate = 1.0; +}; diff --git a/test/wpt/tests/interfaces/console.idl b/test/wpt/tests/interfaces/console.idl new file mode 100644 index 0000000..fdf1d0d --- /dev/null +++ b/test/wpt/tests/interfaces/console.idl @@ -0,0 +1,34 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Console Standard (https://console.spec.whatwg.org/) + +[Exposed=*] +namespace console { // but see namespace object requirements below + // Logging + undefined assert(optional boolean condition = false, any... data); + undefined clear(); + undefined debug(any... data); + undefined error(any... data); + undefined info(any... data); + undefined log(any... data); + undefined table(optional any tabularData, optional sequence properties); + undefined trace(any... data); + undefined warn(any... data); + undefined dir(optional any item, optional object? options); + undefined dirxml(any... data); + + // Counting + undefined count(optional DOMString label = "default"); + undefined countReset(optional DOMString label = "default"); + + // Grouping + undefined group(any... data); + undefined groupCollapsed(any... data); + undefined groupEnd(); + + // Timing + undefined time(optional DOMString label = "default"); + undefined timeLog(optional DOMString label = "default", any... data); + undefined timeEnd(optional DOMString label = "default"); +}; diff --git a/test/wpt/tests/interfaces/contact-picker.idl b/test/wpt/tests/interfaces/contact-picker.idl new file mode 100644 index 0000000..0119d0e --- /dev/null +++ b/test/wpt/tests/interfaces/contact-picker.idl @@ -0,0 +1,44 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Contact Picker API (https://w3c.github.io/contact-picker/) + +[Exposed=Window] +partial interface Navigator { + [SecureContext, SameObject] readonly attribute ContactsManager contacts; +}; + +enum ContactProperty { "address", "email", "icon", "name", "tel" }; + +[Exposed=Window] +interface ContactAddress { + [Default] object toJSON(); + readonly attribute DOMString city; + readonly attribute DOMString country; + readonly attribute DOMString dependentLocality; + readonly attribute DOMString organization; + readonly attribute DOMString phone; + readonly attribute DOMString postalCode; + readonly attribute DOMString recipient; + readonly attribute DOMString region; + readonly attribute DOMString sortingCode; + readonly attribute FrozenArray addressLine; +}; + +dictionary ContactInfo { + sequence address; + sequence email; + sequence icon; + sequence name; + sequence tel; +}; + +dictionary ContactsSelectOptions { + boolean multiple = false; +}; + +[Exposed=Window,SecureContext] +interface ContactsManager { + Promise> getProperties(); + Promise> select(sequence properties, optional ContactsSelectOptions options = {}); +}; diff --git a/test/wpt/tests/interfaces/content-index.idl b/test/wpt/tests/interfaces/content-index.idl new file mode 100644 index 0000000..177c5b9 --- /dev/null +++ b/test/wpt/tests/interfaces/content-index.idl @@ -0,0 +1,46 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Content Index (https://wicg.github.io/content-index/spec/) + +partial interface ServiceWorkerGlobalScope { + attribute EventHandler oncontentdelete; +}; + +partial interface ServiceWorkerRegistration { + [SameObject] readonly attribute ContentIndex index; +}; + +enum ContentCategory { + "", + "homepage", + "article", + "video", + "audio", +}; + +dictionary ContentDescription { + required DOMString id; + required DOMString title; + required DOMString description; + ContentCategory category = ""; + sequence icons = []; + required USVString url; +}; + +[Exposed=(Window,Worker)] +interface ContentIndex { + Promise add(ContentDescription description); + Promise delete(DOMString id); + Promise> getAll(); +}; + +dictionary ContentIndexEventInit : ExtendableEventInit { + required DOMString id; +}; + +[Exposed=ServiceWorker] +interface ContentIndexEvent : ExtendableEvent { + constructor(DOMString type, ContentIndexEventInit init); + readonly attribute DOMString id; +}; diff --git a/test/wpt/tests/interfaces/cookie-store.idl b/test/wpt/tests/interfaces/cookie-store.idl new file mode 100644 index 0000000..f44b4c6 --- /dev/null +++ b/test/wpt/tests/interfaces/cookie-store.idl @@ -0,0 +1,110 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Cookie Store API (https://wicg.github.io/cookie-store/) + +[Exposed=(ServiceWorker,Window), + SecureContext] +interface CookieStore : EventTarget { + Promise get(USVString name); + Promise get(optional CookieStoreGetOptions options = {}); + + Promise getAll(USVString name); + Promise getAll(optional CookieStoreGetOptions options = {}); + + Promise set(USVString name, USVString value); + Promise set(CookieInit options); + + Promise delete(USVString name); + Promise delete(CookieStoreDeleteOptions options); + + [Exposed=Window] + attribute EventHandler onchange; +}; + +dictionary CookieStoreGetOptions { + USVString name; + USVString url; +}; + +enum CookieSameSite { + "strict", + "lax", + "none" +}; + +dictionary CookieInit { + required USVString name; + required USVString value; + DOMHighResTimeStamp? expires = null; + USVString? domain = null; + USVString path = "/"; + CookieSameSite sameSite = "strict"; +}; + +dictionary CookieStoreDeleteOptions { + required USVString name; + USVString? domain = null; + USVString path = "/"; +}; + +dictionary CookieListItem { + USVString name; + USVString value; + USVString? domain; + USVString path; + DOMHighResTimeStamp? expires; + boolean secure; + CookieSameSite sameSite; +}; + +typedef sequence CookieList; + +[Exposed=(ServiceWorker,Window), + SecureContext] +interface CookieStoreManager { + Promise subscribe(sequence subscriptions); + Promise> getSubscriptions(); + Promise unsubscribe(sequence subscriptions); +}; + +[Exposed=(ServiceWorker,Window)] +partial interface ServiceWorkerRegistration { + [SameObject] readonly attribute CookieStoreManager cookies; +}; + +[Exposed=Window, + SecureContext] +interface CookieChangeEvent : Event { + constructor(DOMString type, optional CookieChangeEventInit eventInitDict = {}); + [SameObject] readonly attribute FrozenArray changed; + [SameObject] readonly attribute FrozenArray deleted; +}; + +dictionary CookieChangeEventInit : EventInit { + CookieList changed; + CookieList deleted; +}; + +[Exposed=ServiceWorker] +interface ExtendableCookieChangeEvent : ExtendableEvent { + constructor(DOMString type, optional ExtendableCookieChangeEventInit eventInitDict = {}); + [SameObject] readonly attribute FrozenArray changed; + [SameObject] readonly attribute FrozenArray deleted; +}; + +dictionary ExtendableCookieChangeEventInit : ExtendableEventInit { + CookieList changed; + CookieList deleted; +}; + +[SecureContext] +partial interface Window { + [SameObject] readonly attribute CookieStore cookieStore; +}; + +partial interface ServiceWorkerGlobalScope { + [SameObject] readonly attribute CookieStore cookieStore; + + attribute EventHandler oncookiechange; +}; diff --git a/test/wpt/tests/interfaces/credential-management.idl b/test/wpt/tests/interfaces/credential-management.idl new file mode 100644 index 0000000..e9fab13 --- /dev/null +++ b/test/wpt/tests/interfaces/credential-management.idl @@ -0,0 +1,105 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Credential Management Level 1 (https://w3c.github.io/webappsec-credential-management/) + +[Exposed=Window, SecureContext] +interface Credential { + readonly attribute USVString id; + readonly attribute DOMString type; + static Promise isConditionalMediationAvailable(); +}; + +[SecureContext] +interface mixin CredentialUserData { + readonly attribute USVString name; + readonly attribute USVString iconURL; +}; + +partial interface Navigator { + [SecureContext, SameObject] readonly attribute CredentialsContainer credentials; +}; + +[Exposed=Window, SecureContext] +interface CredentialsContainer { + Promise get(optional CredentialRequestOptions options = {}); + Promise store(Credential credential); + Promise create(optional CredentialCreationOptions options = {}); + Promise preventSilentAccess(); +}; + +dictionary CredentialData { + required USVString id; +}; + +dictionary CredentialRequestOptions { + CredentialMediationRequirement mediation = "optional"; + AbortSignal signal; +}; + +enum CredentialMediationRequirement { + "silent", + "optional", + "conditional", + "required" +}; + +dictionary CredentialCreationOptions { + AbortSignal signal; +}; + +[Exposed=Window, + SecureContext] +interface PasswordCredential : Credential { + constructor(HTMLFormElement form); + constructor(PasswordCredentialData data); + readonly attribute USVString password; +}; +PasswordCredential includes CredentialUserData; + +partial dictionary CredentialRequestOptions { + boolean password = false; +}; + +dictionary PasswordCredentialData : CredentialData { + USVString name; + USVString iconURL; + required USVString origin; + required USVString password; +}; + +typedef (PasswordCredentialData or HTMLFormElement) PasswordCredentialInit; + +partial dictionary CredentialCreationOptions { + PasswordCredentialInit password; +}; + +[Exposed=Window, + SecureContext] +interface FederatedCredential : Credential { + constructor(FederatedCredentialInit data); + readonly attribute USVString provider; + readonly attribute DOMString? protocol; +}; +FederatedCredential includes CredentialUserData; + +dictionary FederatedCredentialRequestOptions { + sequence providers; + sequence protocols; +}; + +partial dictionary CredentialRequestOptions { + FederatedCredentialRequestOptions federated; +}; + +dictionary FederatedCredentialInit : CredentialData { + USVString name; + USVString iconURL; + required USVString origin; + required USVString provider; + DOMString protocol; +}; + +partial dictionary CredentialCreationOptions { + FederatedCredentialInit federated; +}; diff --git a/test/wpt/tests/interfaces/csp-embedded-enforcement.idl b/test/wpt/tests/interfaces/csp-embedded-enforcement.idl new file mode 100644 index 0000000..a980630 --- /dev/null +++ b/test/wpt/tests/interfaces/csp-embedded-enforcement.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Content Security Policy: Embedded Enforcement (https://w3c.github.io/webappsec-cspee/) + +partial interface HTMLIFrameElement { + [CEReactions] attribute DOMString csp; +}; diff --git a/test/wpt/tests/interfaces/csp-next.idl b/test/wpt/tests/interfaces/csp-next.idl new file mode 100644 index 0000000..d94b36c --- /dev/null +++ b/test/wpt/tests/interfaces/csp-next.idl @@ -0,0 +1,21 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: Scripting Policy (https://wicg.github.io/csp-next/scripting-policy.html) + +enum ScriptingPolicyViolationType { + "externalScript", + "inlineScript", + "inlineEventHandler", + "eval" +}; + +[Exposed=(Window,Worker), SecureContext] +interface ScriptingPolicyReportBody : ReportBody { + [Default] object toJSON(); + readonly attribute DOMString violationType; + readonly attribute USVString? violationURL; + readonly attribute USVString? violationSample; + readonly attribute unsigned long lineno; + readonly attribute unsigned long colno; +}; diff --git a/test/wpt/tests/interfaces/css-anchor-position.idl b/test/wpt/tests/interfaces/css-anchor-position.idl new file mode 100644 index 0000000..c5da3f4 --- /dev/null +++ b/test/wpt/tests/interfaces/css-anchor-position.idl @@ -0,0 +1,11 @@ +// Source: CSS Anchor Positioning (https://drafts.csswg.org/css-anchor-position-1/) + +[Exposed=Window] +interface CSSPositionFallbackRule : CSSGroupingRule { + readonly attribute CSSOMString name; +}; + +[Exposed=Window] +interface CSSTryRule : CSSRule { + [SameObject, PutForwards=cssText] readonly attribute CSSStyleDeclaration style; +}; diff --git a/test/wpt/tests/interfaces/css-animation-worklet.idl b/test/wpt/tests/interfaces/css-animation-worklet.idl new file mode 100644 index 0000000..82d34a3 --- /dev/null +++ b/test/wpt/tests/interfaces/css-animation-worklet.idl @@ -0,0 +1,37 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Animation Worklet API (https://drafts.css-houdini.org/css-animationworklet-1/) + +[Exposed=Window] +partial namespace CSS { + [SameObject] readonly attribute Worklet animationWorklet; +}; + +[ Global=(Worklet,AnimationWorklet), Exposed=AnimationWorklet ] +interface AnimationWorkletGlobalScope : WorkletGlobalScope { + undefined registerAnimator(DOMString name, AnimatorInstanceConstructor animatorCtor); +}; + +callback AnimatorInstanceConstructor = any (any options, optional any state); + +[ Exposed=AnimationWorklet ] +interface WorkletAnimationEffect { + EffectTiming getTiming(); + ComputedEffectTiming getComputedTiming(); + attribute double? localTime; +}; + +[Exposed=Window] +interface WorkletAnimation : Animation { + constructor(DOMString animatorName, + optional (AnimationEffect or sequence)? effects = null, + optional AnimationTimeline? timeline, + optional any options); + readonly attribute DOMString animatorName; +}; + +[Exposed=AnimationWorklet] +interface WorkletGroupEffect { + sequence getChildren(); +}; diff --git a/test/wpt/tests/interfaces/css-animations-2.idl b/test/wpt/tests/interfaces/css-animations-2.idl new file mode 100644 index 0000000..84f138e --- /dev/null +++ b/test/wpt/tests/interfaces/css-animations-2.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Animations Level 2 (https://drafts.csswg.org/css-animations-2/) + +[Exposed=Window] +interface CSSAnimation : Animation { + readonly attribute CSSOMString animationName; +}; diff --git a/test/wpt/tests/interfaces/css-animations.idl b/test/wpt/tests/interfaces/css-animations.idl new file mode 100644 index 0000000..6620e01 --- /dev/null +++ b/test/wpt/tests/interfaces/css-animations.idl @@ -0,0 +1,47 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Animations Level 1 (https://drafts.csswg.org/css-animations-1/) + +[Exposed=Window] +interface AnimationEvent : Event { + constructor(CSSOMString type, optional AnimationEventInit animationEventInitDict = {}); + readonly attribute CSSOMString animationName; + readonly attribute double elapsedTime; + readonly attribute CSSOMString pseudoElement; +}; +dictionary AnimationEventInit : EventInit { + CSSOMString animationName = ""; + double elapsedTime = 0.0; + CSSOMString pseudoElement = ""; +}; + +partial interface CSSRule { + const unsigned short KEYFRAMES_RULE = 7; + const unsigned short KEYFRAME_RULE = 8; +}; + +[Exposed=Window] +interface CSSKeyframeRule : CSSRule { + attribute CSSOMString keyText; + [SameObject, PutForwards=cssText] readonly attribute CSSStyleDeclaration style; +}; + +[Exposed=Window] +interface CSSKeyframesRule : CSSRule { + attribute CSSOMString name; + readonly attribute CSSRuleList cssRules; + readonly attribute unsigned long length; + + getter CSSKeyframeRule (unsigned long index); + undefined appendRule(CSSOMString rule); + undefined deleteRule(CSSOMString select); + CSSKeyframeRule? findRule(CSSOMString select); +}; + +partial interface mixin GlobalEventHandlers { + attribute EventHandler onanimationstart; + attribute EventHandler onanimationiteration; + attribute EventHandler onanimationend; + attribute EventHandler onanimationcancel; +}; diff --git a/test/wpt/tests/interfaces/css-cascade-6.idl b/test/wpt/tests/interfaces/css-cascade-6.idl new file mode 100644 index 0000000..3bdf6ba --- /dev/null +++ b/test/wpt/tests/interfaces/css-cascade-6.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Cascading and Inheritance Level 6 (https://drafts.csswg.org/css-cascade-6/) + +[Exposed=Window] +interface CSSScopeRule : CSSGroupingRule { + readonly attribute CSSOMString? start; + readonly attribute CSSOMString? end; +}; diff --git a/test/wpt/tests/interfaces/css-cascade.idl b/test/wpt/tests/interfaces/css-cascade.idl new file mode 100644 index 0000000..0dd9969 --- /dev/null +++ b/test/wpt/tests/interfaces/css-cascade.idl @@ -0,0 +1,14 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Cascading and Inheritance Level 5 (https://drafts.csswg.org/css-cascade-5/) + +[Exposed=Window] +interface CSSLayerBlockRule : CSSGroupingRule { + readonly attribute CSSOMString name; +}; + +[Exposed=Window] +interface CSSLayerStatementRule : CSSRule { + readonly attribute FrozenArray nameList; +}; diff --git a/test/wpt/tests/interfaces/css-color-5.idl b/test/wpt/tests/interfaces/css-color-5.idl new file mode 100644 index 0000000..6f5c6df --- /dev/null +++ b/test/wpt/tests/interfaces/css-color-5.idl @@ -0,0 +1,12 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Color Module Level 5 (https://drafts.csswg.org/css-color-5/) + +[Exposed=Window] +interface CSSColorProfileRule : CSSRule { + readonly attribute CSSOMString name ; + readonly attribute CSSOMString src ; + readonly attribute CSSOMString renderingIntent ; + readonly attribute CSSOMString components ; +}; diff --git a/test/wpt/tests/interfaces/css-conditional.idl b/test/wpt/tests/interfaces/css-conditional.idl new file mode 100644 index 0000000..d87f305 --- /dev/null +++ b/test/wpt/tests/interfaces/css-conditional.idl @@ -0,0 +1,27 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Conditional Rules Module Level 3 (https://drafts.csswg.org/css-conditional-3/) + +partial interface CSSRule { + const unsigned short SUPPORTS_RULE = 12; +}; + +[Exposed=Window] +interface CSSConditionRule : CSSGroupingRule { + readonly attribute CSSOMString conditionText; +}; + +[Exposed=Window] +interface CSSMediaRule : CSSConditionRule { + [SameObject, PutForwards=mediaText] readonly attribute MediaList media; +}; + +[Exposed=Window] +interface CSSSupportsRule : CSSConditionRule { +}; + +partial namespace CSS { + boolean supports(CSSOMString property, CSSOMString value); + boolean supports(CSSOMString conditionText); +}; diff --git a/test/wpt/tests/interfaces/css-contain-3.idl b/test/wpt/tests/interfaces/css-contain-3.idl new file mode 100644 index 0000000..0ecf380 --- /dev/null +++ b/test/wpt/tests/interfaces/css-contain-3.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Containment Module Level 3 (https://drafts.csswg.org/css-contain-3/) + +[Exposed=Window] +interface CSSContainerRule : CSSConditionRule { + readonly attribute CSSOMString containerName; + readonly attribute CSSOMString containerQuery; +}; diff --git a/test/wpt/tests/interfaces/css-contain.idl b/test/wpt/tests/interfaces/css-contain.idl new file mode 100644 index 0000000..be2137a --- /dev/null +++ b/test/wpt/tests/interfaces/css-contain.idl @@ -0,0 +1,13 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Containment Module Level 2 (https://drafts.csswg.org/css-contain-2/) + +[Exposed=Window] +interface ContentVisibilityAutoStateChangeEvent : Event { + constructor(DOMString type, optional ContentVisibilityAutoStateChangeEventInit eventInitDict = {}); + readonly attribute boolean skipped; +}; +dictionary ContentVisibilityAutoStateChangeEventInit : EventInit { + boolean skipped = false; +}; diff --git a/test/wpt/tests/interfaces/css-counter-styles.idl b/test/wpt/tests/interfaces/css-counter-styles.idl new file mode 100644 index 0000000..f679e0f --- /dev/null +++ b/test/wpt/tests/interfaces/css-counter-styles.idl @@ -0,0 +1,23 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Counter Styles Level 3 (https://drafts.csswg.org/css-counter-styles-3/) + +partial interface CSSRule { + const unsigned short COUNTER_STYLE_RULE = 11; +}; + +[Exposed=Window] +interface CSSCounterStyleRule : CSSRule { + attribute CSSOMString name; + attribute CSSOMString system; + attribute CSSOMString symbols; + attribute CSSOMString additiveSymbols; + attribute CSSOMString negative; + attribute CSSOMString prefix; + attribute CSSOMString suffix; + attribute CSSOMString range; + attribute CSSOMString pad; + attribute CSSOMString speakAs; + attribute CSSOMString fallback; +}; diff --git a/test/wpt/tests/interfaces/css-font-loading.idl b/test/wpt/tests/interfaces/css-font-loading.idl new file mode 100644 index 0000000..6f2e16d --- /dev/null +++ b/test/wpt/tests/interfaces/css-font-loading.idl @@ -0,0 +1,134 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Font Loading Module Level 3 (https://drafts.csswg.org/css-font-loading-3/) + +typedef (ArrayBuffer or ArrayBufferView) BinaryData; + +dictionary FontFaceDescriptors { + CSSOMString style = "normal"; + CSSOMString weight = "normal"; + CSSOMString stretch = "normal"; + CSSOMString unicodeRange = "U+0-10FFFF"; + CSSOMString variant = "normal"; + CSSOMString featureSettings = "normal"; + CSSOMString variationSettings = "normal"; + CSSOMString display = "auto"; + CSSOMString ascentOverride = "normal"; + CSSOMString descentOverride = "normal"; + CSSOMString lineGapOverride = "normal"; +}; + +enum FontFaceLoadStatus { "unloaded", "loading", "loaded", "error" }; + +[Exposed=(Window,Worker)] +interface FontFace { + constructor(CSSOMString family, (CSSOMString or BinaryData) source, + optional FontFaceDescriptors descriptors = {}); + attribute CSSOMString family; + attribute CSSOMString style; + attribute CSSOMString weight; + attribute CSSOMString stretch; + attribute CSSOMString unicodeRange; + attribute CSSOMString variant; + attribute CSSOMString featureSettings; + attribute CSSOMString variationSettings; + attribute CSSOMString display; + attribute CSSOMString ascentOverride; + attribute CSSOMString descentOverride; + attribute CSSOMString lineGapOverride; + + readonly attribute FontFaceLoadStatus status; + + Promise load(); + readonly attribute Promise loaded; +}; + +[Exposed=(Window,Worker)] +interface FontFaceFeatures { + /* The CSSWG is still discussing what goes in here */ +}; + +[Exposed=(Window,Worker)] +interface FontFaceVariationAxis { + readonly attribute DOMString name; + readonly attribute DOMString axisTag; + readonly attribute double minimumValue; + readonly attribute double maximumValue; + readonly attribute double defaultValue; +}; + +[Exposed=(Window,Worker)] +interface FontFaceVariations { + readonly setlike; +}; + +[Exposed=(Window,Worker)] +interface FontFacePalette { + iterable; + readonly attribute unsigned long length; + getter DOMString (unsigned long index); + readonly attribute boolean usableWithLightBackground; + readonly attribute boolean usableWithDarkBackground; +}; + +[Exposed=(Window,Worker)] +interface FontFacePalettes { + iterable; + readonly attribute unsigned long length; + getter FontFacePalette (unsigned long index); +}; + +partial interface FontFace { + readonly attribute FontFaceFeatures features; + readonly attribute FontFaceVariations variations; + readonly attribute FontFacePalettes palettes; +}; + +dictionary FontFaceSetLoadEventInit : EventInit { + sequence fontfaces = []; +}; + +[Exposed=(Window,Worker)] +interface FontFaceSetLoadEvent : Event { + constructor(CSSOMString type, optional FontFaceSetLoadEventInit eventInitDict = {}); + [SameObject] readonly attribute FrozenArray fontfaces; +}; + +enum FontFaceSetLoadStatus { "loading", "loaded" }; + +[Exposed=(Window,Worker)] +interface FontFaceSet : EventTarget { + constructor(sequence initialFaces); + + setlike; + FontFaceSet add(FontFace font); + boolean delete(FontFace font); + undefined clear(); + + // events for when loading state changes + attribute EventHandler onloading; + attribute EventHandler onloadingdone; + attribute EventHandler onloadingerror; + + // check and start loads if appropriate + // and fulfill promise when all loads complete + Promise> load(CSSOMString font, optional CSSOMString text = " "); + + // return whether all fonts in the fontlist are loaded + // (does not initiate load if not available) + boolean check(CSSOMString font, optional CSSOMString text = " "); + + // async notification that font loading and layout operations are done + readonly attribute Promise ready; + + // loading state, "loading" while one or more fonts loading, "loaded" otherwise + readonly attribute FontFaceSetLoadStatus status; +}; + +interface mixin FontFaceSource { + readonly attribute FontFaceSet fonts; +}; + +Document includes FontFaceSource; +WorkerGlobalScope includes FontFaceSource; diff --git a/test/wpt/tests/interfaces/css-fonts.idl b/test/wpt/tests/interfaces/css-fonts.idl new file mode 100644 index 0000000..eddfc02 --- /dev/null +++ b/test/wpt/tests/interfaces/css-fonts.idl @@ -0,0 +1,36 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Fonts Module Level 4 (https://drafts.csswg.org/css-fonts-4/) + +[Exposed=Window] +interface CSSFontFaceRule : CSSRule { + readonly attribute CSSStyleDeclaration style; +}; + +partial interface CSSRule { const unsigned short FONT_FEATURE_VALUES_RULE = 14; +}; +[Exposed=Window] +interface CSSFontFeatureValuesRule : CSSRule { + attribute CSSOMString fontFamily; + readonly attribute CSSFontFeatureValuesMap annotation; + readonly attribute CSSFontFeatureValuesMap ornaments; + readonly attribute CSSFontFeatureValuesMap stylistic; + readonly attribute CSSFontFeatureValuesMap swash; + readonly attribute CSSFontFeatureValuesMap characterVariant; + readonly attribute CSSFontFeatureValuesMap styleset; +}; + +[Exposed=Window] +interface CSSFontFeatureValuesMap { + maplike>; + undefined set(CSSOMString featureValueName, + (unsigned long or sequence) values); +}; + +[Exposed=Window]interface CSSFontPaletteValuesRule : CSSRule { + readonly attribute CSSOMString name; + readonly attribute CSSOMString fontFamily; + readonly attribute CSSOMString basePalette; + readonly attribute CSSOMString overrideColors; +}; diff --git a/test/wpt/tests/interfaces/css-highlight-api.idl b/test/wpt/tests/interfaces/css-highlight-api.idl new file mode 100644 index 0000000..f3c6b2e --- /dev/null +++ b/test/wpt/tests/interfaces/css-highlight-api.idl @@ -0,0 +1,27 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Custom Highlight API Module Level 1 (https://drafts.csswg.org/css-highlight-api-1/) + +enum HighlightType { + "highlight", + "spelling-error", + "grammar-error" +}; + +[Exposed=Window] +interface Highlight { + constructor(AbstractRange... initialRanges); + setlike; + attribute long priority; + attribute HighlightType type; +}; + +partial namespace CSS { + readonly attribute HighlightRegistry highlights; +}; + +[Exposed=Window] +interface HighlightRegistry { + maplike; +}; diff --git a/test/wpt/tests/interfaces/css-images-4.idl b/test/wpt/tests/interfaces/css-images-4.idl new file mode 100644 index 0000000..8866b00 --- /dev/null +++ b/test/wpt/tests/interfaces/css-images-4.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Images Module Level 4 (https://drafts.csswg.org/css-images-4/) + +partial namespace CSS { + [SameObject] readonly attribute any elementSources; +}; diff --git a/test/wpt/tests/interfaces/css-layout-api.idl b/test/wpt/tests/interfaces/css-layout-api.idl new file mode 100644 index 0000000..2b772d5 --- /dev/null +++ b/test/wpt/tests/interfaces/css-layout-api.idl @@ -0,0 +1,144 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Layout API Level 1 (https://drafts.css-houdini.org/css-layout-api-1/) + +partial namespace CSS { + [SameObject] readonly attribute Worklet layoutWorklet; +}; + +[Global=(Worklet,LayoutWorklet),Exposed=LayoutWorklet] +interface LayoutWorkletGlobalScope : WorkletGlobalScope { + undefined registerLayout(DOMString name, VoidFunction layoutCtor); +}; + +dictionary LayoutOptions { + ChildDisplayType childDisplay = "block"; + LayoutSizingMode sizing = "block-like"; +}; + +enum ChildDisplayType { + "block", // default - "blockifies" the child boxes. + "normal", +}; + +enum LayoutSizingMode { + "block-like", // default - Sizing behaves like block containers. + "manual", // Sizing is specified by the web developer. +}; + +[Exposed=LayoutWorklet] +interface LayoutChild { + readonly attribute StylePropertyMapReadOnly styleMap; + + Promise intrinsicSizes(); + Promise layoutNextFragment(LayoutConstraintsOptions constraints, ChildBreakToken breakToken); +}; + +[Exposed=LayoutWorklet] +interface LayoutFragment { + readonly attribute double inlineSize; + readonly attribute double blockSize; + + attribute double inlineOffset; + attribute double blockOffset; + + readonly attribute any data; + + readonly attribute ChildBreakToken? breakToken; +}; + +[Exposed=LayoutWorklet] +interface IntrinsicSizes { + readonly attribute double minContentSize; + readonly attribute double maxContentSize; +}; + +[Exposed=LayoutWorklet] +interface LayoutConstraints { + readonly attribute double availableInlineSize; + readonly attribute double availableBlockSize; + + readonly attribute double? fixedInlineSize; + readonly attribute double? fixedBlockSize; + + readonly attribute double percentageInlineSize; + readonly attribute double percentageBlockSize; + + readonly attribute double? blockFragmentationOffset; + readonly attribute BlockFragmentationType blockFragmentationType; + + readonly attribute any data; +}; + +enum BlockFragmentationType { "none", "page", "column", "region" }; + +dictionary LayoutConstraintsOptions { + double availableInlineSize; + double availableBlockSize; + + double fixedInlineSize; + double fixedBlockSize; + + double percentageInlineSize; + double percentageBlockSize; + + double blockFragmentationOffset; + BlockFragmentationType blockFragmentationType = "none"; + + any data; +}; + +[Exposed=LayoutWorklet] +interface ChildBreakToken { + readonly attribute BreakType breakType; + readonly attribute LayoutChild child; +}; + +[Exposed=LayoutWorklet] +interface BreakToken { + readonly attribute FrozenArray childBreakTokens; + readonly attribute any data; +}; + +dictionary BreakTokenOptions { + sequence childBreakTokens; + any data = null; +}; + +enum BreakType { "none", "line", "column", "page", "region" }; + +[Exposed=LayoutWorklet] +interface LayoutEdges { + readonly attribute double inlineStart; + readonly attribute double inlineEnd; + + readonly attribute double blockStart; + readonly attribute double blockEnd; + + // Convenience attributes for the sum in one direction. + readonly attribute double inline; + readonly attribute double block; +}; + +// This is the final return value from the author defined layout() method. +dictionary FragmentResultOptions { + double inlineSize = 0; + double blockSize = 0; + double autoBlockSize = 0; + sequence childFragments = []; + any data = null; + BreakTokenOptions breakToken = null; +}; + +[Exposed=LayoutWorklet] +interface FragmentResult { + constructor(optional FragmentResultOptions options = {}); + readonly attribute double inlineSize; + readonly attribute double blockSize; +}; + +dictionary IntrinsicSizesResultOptions { + double maxContentSize; + double minContentSize; +}; diff --git a/test/wpt/tests/interfaces/css-masking.idl b/test/wpt/tests/interfaces/css-masking.idl new file mode 100644 index 0000000..72fbd9a --- /dev/null +++ b/test/wpt/tests/interfaces/css-masking.idl @@ -0,0 +1,20 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Masking Module Level 1 (https://drafts.fxtf.org/css-masking-1/) + +[Exposed=Window] +interface SVGClipPathElement : SVGElement { + readonly attribute SVGAnimatedEnumeration clipPathUnits; + readonly attribute SVGAnimatedTransformList transform; +}; + +[Exposed=Window] +interface SVGMaskElement : SVGElement { + readonly attribute SVGAnimatedEnumeration maskUnits; + readonly attribute SVGAnimatedEnumeration maskContentUnits; + readonly attribute SVGAnimatedLength x; + readonly attribute SVGAnimatedLength y; + readonly attribute SVGAnimatedLength width; + readonly attribute SVGAnimatedLength height; +}; diff --git a/test/wpt/tests/interfaces/css-nav.idl b/test/wpt/tests/interfaces/css-nav.idl new file mode 100644 index 0000000..03f039e --- /dev/null +++ b/test/wpt/tests/interfaces/css-nav.idl @@ -0,0 +1,48 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Spatial Navigation Level 1 (https://drafts.csswg.org/css-nav-1/) + +enum SpatialNavigationDirection { + "up", + "down", + "left", + "right", +}; + +partial interface Window { + undefined navigate(SpatialNavigationDirection dir); +}; + +enum FocusableAreaSearchMode { + "visible", + "all" +}; + +dictionary FocusableAreasOption { + FocusableAreaSearchMode mode; +}; + +dictionary SpatialNavigationSearchOptions { + sequence? candidates; + Node? container; +}; + +partial interface Element { + Node getSpatialNavigationContainer(); + sequence focusableAreas(optional FocusableAreasOption option = {}); + Node? spatialNavigationSearch(SpatialNavigationDirection dir, optional SpatialNavigationSearchOptions options = {}); +}; + +[Exposed=Window] +interface NavigationEvent : UIEvent { + constructor(DOMString type, + optional NavigationEventInit eventInitDict = {}); + readonly attribute SpatialNavigationDirection dir; + readonly attribute EventTarget? relatedTarget; +}; + +dictionary NavigationEventInit : UIEventInit { + SpatialNavigationDirection dir; + EventTarget? relatedTarget = null; +}; diff --git a/test/wpt/tests/interfaces/css-nesting.idl b/test/wpt/tests/interfaces/css-nesting.idl new file mode 100644 index 0000000..01f27ab --- /dev/null +++ b/test/wpt/tests/interfaces/css-nesting.idl @@ -0,0 +1,10 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Nesting Module (https://drafts.csswg.org/css-nesting-1/) + +partial interface CSSStyleRule { + [SameObject] readonly attribute CSSRuleList cssRules; + unsigned long insertRule(CSSOMString rule, optional unsigned long index = 0); + undefined deleteRule(unsigned long index); +}; diff --git a/test/wpt/tests/interfaces/css-paint-api.idl b/test/wpt/tests/interfaces/css-paint-api.idl new file mode 100644 index 0000000..0924c53 --- /dev/null +++ b/test/wpt/tests/interfaces/css-paint-api.idl @@ -0,0 +1,39 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Painting API Level 1 (https://drafts.css-houdini.org/css-paint-api-1/) + +partial namespace CSS { + [SameObject] readonly attribute Worklet paintWorklet; +}; + +[Global=(Worklet,PaintWorklet),Exposed=PaintWorklet] +interface PaintWorkletGlobalScope : WorkletGlobalScope { + undefined registerPaint(DOMString name, VoidFunction paintCtor); + readonly attribute unrestricted double devicePixelRatio; +}; + +dictionary PaintRenderingContext2DSettings { + boolean alpha = true; +}; + +[Exposed=PaintWorklet] +interface PaintRenderingContext2D { +}; +PaintRenderingContext2D includes CanvasState; +PaintRenderingContext2D includes CanvasTransform; +PaintRenderingContext2D includes CanvasCompositing; +PaintRenderingContext2D includes CanvasImageSmoothing; +PaintRenderingContext2D includes CanvasFillStrokeStyles; +PaintRenderingContext2D includes CanvasShadowStyles; +PaintRenderingContext2D includes CanvasRect; +PaintRenderingContext2D includes CanvasDrawPath; +PaintRenderingContext2D includes CanvasDrawImage; +PaintRenderingContext2D includes CanvasPathDrawingStyles; +PaintRenderingContext2D includes CanvasPath; + +[Exposed=PaintWorklet] +interface PaintSize { + readonly attribute double width; + readonly attribute double height; +}; diff --git a/test/wpt/tests/interfaces/css-parser-api.idl b/test/wpt/tests/interfaces/css-parser-api.idl new file mode 100644 index 0000000..4e34a3f --- /dev/null +++ b/test/wpt/tests/interfaces/css-parser-api.idl @@ -0,0 +1,76 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Parser API (https://wicg.github.io/css-parser-api/) + +typedef (DOMString or ReadableStream) CSSStringSource; +typedef (DOMString or CSSStyleValue or CSSParserValue) CSSToken; + +partial namespace CSS { + Promise> parseStylesheet(CSSStringSource css, optional CSSParserOptions options = {}); + Promise> parseRuleList(CSSStringSource css, optional CSSParserOptions options = {}); + Promise parseRule(CSSStringSource css, optional CSSParserOptions options = {}); + Promise> parseDeclarationList(CSSStringSource css, optional CSSParserOptions options = {}); + CSSParserDeclaration parseDeclaration(DOMString css, optional CSSParserOptions options = {}); + CSSToken parseValue(DOMString css); + sequence parseValueList(DOMString css); + sequence> parseCommaValueList(DOMString css); +}; + +dictionary CSSParserOptions { + object atRules; + /* dict of at-rule name => at-rule type + (contains decls or contains qualified rules) */ +}; + +[Exposed=Window] +interface CSSParserRule { + /* Just a superclass. */ +}; + +[Exposed=Window] +interface CSSParserAtRule : CSSParserRule { + constructor(DOMString name, sequence prelude, optional sequence? body); + readonly attribute DOMString name; + readonly attribute FrozenArray prelude; + readonly attribute FrozenArray? body; + /* nullable to handle at-statements */ + stringifier; +}; + +[Exposed=Window] +interface CSSParserQualifiedRule : CSSParserRule { + constructor(sequence prelude, optional sequence? body); + readonly attribute FrozenArray prelude; + readonly attribute FrozenArray body; + stringifier; +}; + +[Exposed=Window] +interface CSSParserDeclaration : CSSParserRule { + constructor(DOMString name, optional sequence body); + readonly attribute DOMString name; + readonly attribute FrozenArray body; + stringifier; +}; + +[Exposed=Window] +interface CSSParserValue { + /* Just a superclass. */ +}; + +[Exposed=Window] +interface CSSParserBlock : CSSParserValue { + constructor(DOMString name, sequence body); + readonly attribute DOMString name; /* "[]", "{}", or "()" */ + readonly attribute FrozenArray body; + stringifier; +}; + +[Exposed=Window] +interface CSSParserFunction : CSSParserValue { + constructor(DOMString name, sequence> args); + readonly attribute DOMString name; + readonly attribute FrozenArray> args; + stringifier; +}; diff --git a/test/wpt/tests/interfaces/css-properties-values-api.idl b/test/wpt/tests/interfaces/css-properties-values-api.idl new file mode 100644 index 0000000..eb7d7b0 --- /dev/null +++ b/test/wpt/tests/interfaces/css-properties-values-api.idl @@ -0,0 +1,23 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Properties and Values API Level 1 (https://drafts.css-houdini.org/css-properties-values-api-1/) + +dictionary PropertyDefinition { + required DOMString name; + DOMString syntax = "*"; + required boolean inherits; + DOMString initialValue; +}; + +partial namespace CSS { + undefined registerProperty(PropertyDefinition definition); +}; + +[Exposed=Window] +interface CSSPropertyRule : CSSRule { + readonly attribute CSSOMString name; + readonly attribute CSSOMString syntax; + readonly attribute boolean inherits; + readonly attribute CSSOMString? initialValue; +}; diff --git a/test/wpt/tests/interfaces/css-pseudo.idl b/test/wpt/tests/interfaces/css-pseudo.idl new file mode 100644 index 0000000..dbe4c54 --- /dev/null +++ b/test/wpt/tests/interfaces/css-pseudo.idl @@ -0,0 +1,16 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Pseudo-Elements Module Level 4 (https://drafts.csswg.org/css-pseudo-4/) + +[Exposed=Window] +interface CSSPseudoElement : EventTarget { + readonly attribute CSSOMString type; + readonly attribute Element element; + readonly attribute (Element or CSSPseudoElement) parent; + CSSPseudoElement? pseudo(CSSOMString type); +}; + +partial interface Element { + CSSPseudoElement? pseudo(CSSOMString type); +}; diff --git a/test/wpt/tests/interfaces/css-regions.idl b/test/wpt/tests/interfaces/css-regions.idl new file mode 100644 index 0000000..113438f --- /dev/null +++ b/test/wpt/tests/interfaces/css-regions.idl @@ -0,0 +1,29 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Regions Module Level 1 (https://drafts.csswg.org/css-regions-1/) + +partial interface Document { + readonly attribute NamedFlowMap namedFlows; +}; + +[Exposed=Window] interface NamedFlowMap { + maplike; +}; + +[Exposed=Window] +interface NamedFlow : EventTarget { + readonly attribute CSSOMString name; + readonly attribute boolean overset; + sequence getRegions(); + readonly attribute short firstEmptyRegionIndex; + sequence getContent(); + sequence getRegionsByContent(Node node); +}; + +interface mixin Region { + readonly attribute CSSOMString regionOverset; + sequence? getRegionFlowRanges(); +}; + +Element includes Region; diff --git a/test/wpt/tests/interfaces/css-shadow-parts.idl b/test/wpt/tests/interfaces/css-shadow-parts.idl new file mode 100644 index 0000000..3759199 --- /dev/null +++ b/test/wpt/tests/interfaces/css-shadow-parts.idl @@ -0,0 +1,8 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Shadow Parts (https://drafts.csswg.org/css-shadow-parts-1/) + +partial interface Element { + [SameObject, PutForwards=value] readonly attribute DOMTokenList part; +}; diff --git a/test/wpt/tests/interfaces/css-toggle.tentative.idl b/test/wpt/tests/interfaces/css-toggle.tentative.idl new file mode 100644 index 0000000..5587019 --- /dev/null +++ b/test/wpt/tests/interfaces/css-toggle.tentative.idl @@ -0,0 +1,51 @@ +partial interface Element { + [SameObject] readonly attribute CSSToggleMap toggles; +}; + +interface CSSToggleMap { + maplike; + CSSToggleMap set(DOMString key, CSSToggle value); +}; + +interface CSSToggle { + attribute (unsigned long or DOMString) value; + attribute unsigned long? valueAsNumber; + attribute DOMString? valueAsString; + + attribute (unsigned long or FrozenArray) states; + attribute boolean group; + attribute CSSToggleScope scope; + attribute CSSToggleCycle cycle; + + constructor(optional CSSToggleData options); +}; + +dictionary CSSToggleData { + (unsigned long or DOMString) value = 0; + (unsigned long or sequence) states = 1; + boolean group = false; + CSSToggleScope scope = "wide"; + CSSToggleCycle cycle = "cycle"; +}; + +enum CSSToggleScope { + "narrow", + "wide", +}; + +enum CSSToggleCycle { + "cycle", + "cycle-on", + "sticky", +}; + +interface CSSToggleEvent : Event { + constructor(DOMString type, optional CSSToggleEventInit eventInitDict = {}); + readonly attribute DOMString toggleName; + readonly attribute CSSToggle? toggle; +}; + +dictionary CSSToggleEventInit : EventInit { + DOMString toggleName = ""; + CSSToggle? toggle = null; +}; diff --git a/test/wpt/tests/interfaces/css-transitions-2.idl b/test/wpt/tests/interfaces/css-transitions-2.idl new file mode 100644 index 0000000..9d06f3c --- /dev/null +++ b/test/wpt/tests/interfaces/css-transitions-2.idl @@ -0,0 +1,9 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Transitions Level 2 (https://drafts.csswg.org/css-transitions-2/) + +[Exposed=Window] +interface CSSTransition : Animation { + readonly attribute CSSOMString transitionProperty; +}; diff --git a/test/wpt/tests/interfaces/css-transitions.idl b/test/wpt/tests/interfaces/css-transitions.idl new file mode 100644 index 0000000..0f00b2c --- /dev/null +++ b/test/wpt/tests/interfaces/css-transitions.idl @@ -0,0 +1,25 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Transitions (https://drafts.csswg.org/css-transitions-1/) + +[Exposed=Window] +interface TransitionEvent : Event { + constructor(CSSOMString type, optional TransitionEventInit transitionEventInitDict = {}); + readonly attribute CSSOMString propertyName; + readonly attribute double elapsedTime; + readonly attribute CSSOMString pseudoElement; +}; + +dictionary TransitionEventInit : EventInit { + CSSOMString propertyName = ""; + double elapsedTime = 0.0; + CSSOMString pseudoElement = ""; +}; + +partial interface mixin GlobalEventHandlers { + attribute EventHandler ontransitionrun; + attribute EventHandler ontransitionstart; + attribute EventHandler ontransitionend; + attribute EventHandler ontransitioncancel; +}; diff --git a/test/wpt/tests/interfaces/css-typed-om.idl b/test/wpt/tests/interfaces/css-typed-om.idl new file mode 100644 index 0000000..0df6a03 --- /dev/null +++ b/test/wpt/tests/interfaces/css-typed-om.idl @@ -0,0 +1,423 @@ +// GENERATED CONTENT - DO NOT EDIT +// Content was automatically extracted by Reffy into webref +// (https://github.com/w3c/webref) +// Source: CSS Typed OM Level 1 (https://drafts.css-houdini.org/css-typed-om-1/) + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSStyleValue { + stringifier; + [Exposed=Window] static CSSStyleValue parse(USVString property, USVString cssText); + [Exposed=Window] static sequence parseAll(USVString property, USVString cssText); +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface StylePropertyMapReadOnly { + iterable>; + (undefined or CSSStyleValue) get(USVString property); + sequence getAll(USVString property); + boolean has(USVString property); + readonly attribute unsigned long size; +}; + +[Exposed=Window] +interface StylePropertyMap : StylePropertyMapReadOnly { + undefined set(USVString property, (CSSStyleValue or USVString)... values); + undefined append(USVString property, (CSSStyleValue or USVString)... values); + undefined delete(USVString property); + undefined clear(); +}; + +partial interface Element { + [SameObject] StylePropertyMapReadOnly computedStyleMap(); +}; + +partial interface CSSStyleRule { + [SameObject] readonly attribute StylePropertyMap styleMap; +}; + +partial interface mixin ElementCSSInlineStyle { + [SameObject] readonly attribute StylePropertyMap attributeStyleMap; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSUnparsedValue : CSSStyleValue { + constructor(sequence members); + iterable; + readonly attribute unsigned long length; + getter CSSUnparsedSegment (unsigned long index); + setter CSSUnparsedSegment (unsigned long index, CSSUnparsedSegment val); +}; + +typedef (USVString or CSSVariableReferenceValue) CSSUnparsedSegment; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSVariableReferenceValue { + constructor(USVString variable, optional CSSUnparsedValue? fallback = null); + attribute USVString variable; + readonly attribute CSSUnparsedValue? fallback; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSKeywordValue : CSSStyleValue { + constructor(USVString value); + attribute USVString value; +}; + +typedef (DOMString or CSSKeywordValue) CSSKeywordish; + +typedef (double or CSSNumericValue) CSSNumberish; + +enum CSSNumericBaseType { + "length", + "angle", + "time", + "frequency", + "resolution", + "flex", + "percent", +}; + +dictionary CSSNumericType { + long length; + long angle; + long time; + long frequency; + long resolution; + long flex; + long percent; + CSSNumericBaseType percentHint; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSNumericValue : CSSStyleValue { + CSSNumericValue add(CSSNumberish... values); + CSSNumericValue sub(CSSNumberish... values); + CSSNumericValue mul(CSSNumberish... values); + CSSNumericValue div(CSSNumberish... values); + CSSNumericValue min(CSSNumberish... values); + CSSNumericValue max(CSSNumberish... values); + + boolean equals(CSSNumberish... value); + + CSSUnitValue to(USVString unit); + CSSMathSum toSum(USVString... units); + CSSNumericType type(); + + [Exposed=Window] static CSSNumericValue parse(USVString cssText); +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSUnitValue : CSSNumericValue { + constructor(double value, USVString unit); + attribute double value; + readonly attribute USVString unit; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathValue : CSSNumericValue { + readonly attribute CSSMathOperator operator; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathSum : CSSMathValue { + constructor(CSSNumberish... args); + readonly attribute CSSNumericArray values; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathProduct : CSSMathValue { + constructor(CSSNumberish... args); + readonly attribute CSSNumericArray values; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathNegate : CSSMathValue { + constructor(CSSNumberish arg); + readonly attribute CSSNumericValue value; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathInvert : CSSMathValue { + constructor(CSSNumberish arg); + readonly attribute CSSNumericValue value; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathMin : CSSMathValue { + constructor(CSSNumberish... args); + readonly attribute CSSNumericArray values; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathMax : CSSMathValue { + constructor(CSSNumberish... args); + readonly attribute CSSNumericArray values; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSMathClamp : CSSMathValue { + constructor(CSSNumberish lower, CSSNumberish value, CSSNumberish upper); + readonly attribute CSSNumericValue lower; + readonly attribute CSSNumericValue value; + readonly attribute CSSNumericValue upper; +}; + +[Exposed=(Window, Worker, PaintWorklet, LayoutWorklet)] +interface CSSNumericArray { + iterable; + readonly attribute unsigned long length; + getter CSSNumericValue (unsigned long index); +}; + +enum CSSMathOperator { + "sum", + "product", + "negate", + "invert", + "min", + "max", + "clamp", +}; + +partial namespace CSS { + CSSUnitValue number(double value); + CSSUnitValue percent(double value); + + // + CSSUnitValue em(double value); + CSSUnitValue ex(double value); + CSSUnitValue ch(double value); + CSSUnitValue ic(double value); + CSSUnitValue rem(double value); + CSSUnitValue lh(double value); + CSSUnitValue rlh(double value); + CSSUnitValue vw(double value); + CSSUnitValue vh(double value); + CSSUnitValue vi(double value); + CSSUnitValue vb(double value); + CSSUnitValue vmin(double value); + CSSUnitValue vmax(double value); + CSSUnitValue svw(double value); + CSSUnitValue svh(double value); + CSSUnitValue svi(double value); + CSSUnitValue svb(double value); + CSSUnitValue svmin(double value); + CSSUnitValue svmax(double value); + CSSUnitValue lvw(double value); + CSSUnitValue lvh(double value); + CSSUnitValue lvi(double value); + CSSUnitValue lvb(double value); + CSSUnitValue lvmin(double value); + CSSUnitValue lvmax(double value); + CSSUnitValue dvw(double value); + CSSUnitValue dvh(double value); + CSSUnitValue dvi(double value); + CSSUnitValue dvb(double value); + CSSUnitValue dvmin(double value); + CSSUnitValue dvmax(double value); + CSSUnitValue cqw(double value); + CSSUnitValue cqh(double value); + CSSUnitValue cqi(double value); + CSSUnitValue cqb(double value); + CSSUnitValue cqmin(double value); + CSSUnitValue cqmax(double value); + CSSUnitValue cm(double value); + CSSUnitValue mm(double value); + CSSUnitValue Q(double value); + CSSUnitValue in(double value); + CSSUnitValue pt(double value); + CSSUnitValue pc(double value); + CSSUnitValue px(double value); + + // + CSSUnitValue deg(double value); + CSSUnitValue grad(double value); + CSSUnitValue rad(double value); + CSSUnitValue turn(double value); + + //

Relevant issue: +<embed> should support loading random HTML documents, like <object> +